This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new 0d40ac56c6d branch-4.1: [improvement](regression) Use Spark thrift
JDBC for exter… (#65198)
0d40ac56c6d is described below
commit 0d40ac56c6de9f6f9e937e3e4730ff6735df4e0e
Author: zgxme <[email protected]>
AuthorDate: Sat Jul 4 12:31:35 2026 +0800
branch-4.1: [improvement](regression) Use Spark thrift JDBC for exter…
(#65198)
### What problem does this PR solve?
bp https://github.com/apache/doris/pull/64886
Issue Number: close #xxx
Related PR: #xxx
Problem Summary:
### Release note
None
### Check List (For Author)
- Test <!-- At least one of them must be included. -->
- [ ] Regression test
- [ ] Unit Test
- [ ] Manual test (add detailed scripts or steps below)
- [ ] No need to test or manual test. Explain why:
- [ ] This is a refactor/code format and no logic has been changed.
- [ ] Previous test can cover this change.
- [ ] No code files have been changed.
- [ ] Other reason <!-- Add your reason? -->
- Behavior changed:
- [ ] No.
- [ ] Yes. <!-- Explain the behavior change -->
- Does this need documentation?
- [ ] No.
- [ ] Yes. <!-- Add document PR link here. eg:
https://github.com/apache/doris-website/pull/1214 -->
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label <!-- Add branch pick label that this PR
should merge into -->
---
.../docker-compose/iceberg/entrypoint.sh.tpl | 37 +-
.../docker-compose/iceberg/iceberg.env | 1 +
.../docker-compose/iceberg/iceberg.yaml.tpl | 2 +
.../create_preinstalled_scripts/paimon/run06.sql | 2 +-
.../docker-compose/iceberg/spark-defaults.conf | 3 +-
.../org/apache/doris/regression/suite/Suite.groovy | 133 ++--
.../doris/regression/suite/SuiteContext.groovy | 35 +
.../apache/doris/regression/util/JdbcUtils.groovy | 9 +-
.../doris/regression/util/ResultUtils.groovy | 756 +++++++++++++++++++++
...est_iceberg_spark_doris_consistency_demo.groovy | 301 ++++++++
...test_paimon_spark_doris_consistency_demo.groovy | 317 +++++++++
11 files changed, 1505 insertions(+), 91 deletions(-)
diff --git a/docker/thirdparties/docker-compose/iceberg/entrypoint.sh.tpl
b/docker/thirdparties/docker-compose/iceberg/entrypoint.sh.tpl
index 4232b4f3cc1..7c5dba39109 100644
--- a/docker/thirdparties/docker-compose/iceberg/entrypoint.sh.tpl
+++ b/docker/thirdparties/docker-compose/iceberg/entrypoint.sh.tpl
@@ -19,13 +19,14 @@
export SPARK_MASTER_HOST=doris--spark-iceberg
# wait iceberg-rest start
-while [[ ! $(curl -s --fail http://rest:8181/v1/config) ]]; do
+while ! curl -s --fail http://rest:8181/v1/config >/dev/null; do
sleep 1
done
set -ex
mkdir -p /opt/spark/events
+SPARK_THRIFT_EXTENSIONS="org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions,org.apache.paimon.spark.extensions.PaimonSparkSessionExtensions"
for f in /opt/spark/sbin/*; do
ln -s $f /usr/local/bin/$(basename $f)
@@ -39,7 +40,6 @@ done
start-master.sh -p 7077
start-worker.sh spark://doris--spark-iceberg:7077
start-history-server.sh
-start-thriftserver.sh --driver-java-options "-Dderby.system.home=/tmp/derby"
# The creation of a Spark SQL client is time-consuming,
# and reopening a new client for each SQL file execution leads to significant
overhead.
@@ -68,6 +68,39 @@ END_TIME3=$(date +%s)
EXECUTION_TIME3=$((END_TIME3 - START_TIME3))
echo "Script iceberg load total: {} executed in $EXECUTION_TIME3 seconds"
+spark-sql \
+ --master spark://doris--spark-iceberg:7077 \
+ --conf
spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions
\
+ -e "CREATE DATABASE IF NOT EXISTS demo.default"
+
+start-thriftserver.sh \
+ --master spark://doris--spark-iceberg:7077 \
+ --conf "spark.sql.extensions=${SPARK_THRIFT_EXTENSIONS}" \
+ --conf spark.dynamicAllocation.enabled=false \
+ --conf spark.cores.max=8 \
+ --conf spark.executor.cores=4 \
+ --conf spark.executor.memory=1g \
+ --conf spark.driver.memory=1g \
+ --conf spark.sql.shuffle.partitions=16 \
+ --conf spark.default.parallelism=16 \
+ --driver-java-options "-Dderby.system.home=/tmp/derby"
+
+SPARK_THRIFT_READY_ATTEMPTS=0
+while ! beeline \
+ -u "jdbc:hive2://localhost:10000/default" \
+ -n hadoop \
+ -p hadoop \
+ -e "SELECT 1" >/tmp/spark-thriftserver-ready.log 2>&1; do
+ SPARK_THRIFT_READY_ATTEMPTS=$((SPARK_THRIFT_READY_ATTEMPTS + 1))
+ if [ "${SPARK_THRIFT_READY_ATTEMPTS}" -ge 120 ]; then
+ echo "ERROR: Spark thriftserver did not become ready after
${SPARK_THRIFT_READY_ATTEMPTS} attempts" >&2
+ cat /tmp/spark-thriftserver-ready.log >&2 || true
+ tail -n 200 /opt/spark/logs/*HiveThriftServer2*.out >&2 || true
+ exit 1
+ fi
+ sleep 1
+done
+
touch /mnt/SUCCESS;
tail -f /dev/null
diff --git a/docker/thirdparties/docker-compose/iceberg/iceberg.env
b/docker/thirdparties/docker-compose/iceberg/iceberg.env
index 6bebd49f437..0950783075c 100644
--- a/docker/thirdparties/docker-compose/iceberg/iceberg.env
+++ b/docker/thirdparties/docker-compose/iceberg/iceberg.env
@@ -19,6 +19,7 @@
NOTEBOOK_SERVER_PORT=8888
SPARK_DRIVER_UI_PORT=8080
SPARK_HISTORY_UI_PORT=10000
+SPARK_THRIFT_PORT=11000
REST_CATALOG_PORT=18181
MINIO_UI_PORT=9000
MINIO_API_PORT=19001
diff --git a/docker/thirdparties/docker-compose/iceberg/iceberg.yaml.tpl
b/docker/thirdparties/docker-compose/iceberg/iceberg.yaml.tpl
index 83c1ee6d031..e4e922d1666 100644
--- a/docker/thirdparties/docker-compose/iceberg/iceberg.yaml.tpl
+++ b/docker/thirdparties/docker-compose/iceberg/iceberg.yaml.tpl
@@ -41,6 +41,8 @@ services:
- AWS_ACCESS_KEY_ID=admin
- AWS_SECRET_ACCESS_KEY=password
- AWS_REGION=us-east-1
+ ports:
+ - ${SPARK_THRIFT_PORT}:10000
entrypoint: /bin/sh /mnt/scripts/entrypoint.sh
user: root
networks:
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/create_preinstalled_scripts/paimon/run06.sql
b/docker/thirdparties/docker-compose/iceberg/scripts/create_preinstalled_scripts/paimon/run06.sql
index eb60255a08e..026bd8aab72 100644
---
a/docker/thirdparties/docker-compose/iceberg/scripts/create_preinstalled_scripts/paimon/run06.sql
+++
b/docker/thirdparties/docker-compose/iceberg/scripts/create_preinstalled_scripts/paimon/run06.sql
@@ -228,4 +228,4 @@ VALUES (1, NULL, 100.0),
(2, 'NULL', 200.0),
(3, '\\N', 300.0),
(4, 'null', 400.0),
- (5, 'A', 500.0);
\ No newline at end of file
+ (5, 'A', 500.0);
diff --git a/docker/thirdparties/docker-compose/iceberg/spark-defaults.conf
b/docker/thirdparties/docker-compose/iceberg/spark-defaults.conf
index 8336a2afcf8..f05bf40726f 100644
--- a/docker/thirdparties/docker-compose/iceberg/spark-defaults.conf
+++ b/docker/thirdparties/docker-compose/iceberg/spark-defaults.conf
@@ -20,6 +20,7 @@
# Example:
spark.sql.session.timeZone Asia/Shanghai
+
spark.sql.catalog.demo
org.apache.iceberg.spark.SparkCatalog
spark.sql.catalog.demo.type rest
spark.sql.catalog.demo.uri http://rest:8181
@@ -42,4 +43,4 @@ spark.sql.catalog.paimon.warehouse
s3://warehouse/wh
spark.sql.catalog.paimon.s3.endpoint http://minio:9000
spark.sql.catalog.paimon.s3.access-key admin
spark.sql.catalog.paimon.s3.secret-key password
-spark.sql.catalog.paimon.s3.region us-east-1
\ No newline at end of file
+spark.sql.catalog.paimon.s3.region us-east-1
diff --git
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
index 192f1c2fb7f..0075e501545 100644
---
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
+++
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
@@ -53,6 +53,7 @@ import org.apache.doris.regression.util.DataUtils
import org.apache.doris.regression.util.JdbcUtils
import org.apache.doris.regression.util.Hdfs
import org.apache.doris.regression.util.Http
+import org.apache.doris.regression.util.ResultUtils
import org.apache.doris.regression.util.SuiteUtils
import org.apache.doris.regression.util.DebugPoint
import org.apache.doris.regression.RunMode
@@ -1608,74 +1609,41 @@ class Suite implements GroovyInterceptable {
return result
}
- /**
- * Get the spark-iceberg container name by querying docker.
- * Uses 'docker ps --filter name=spark-iceberg' to find the container.
- */
- private String getSparkIcebergContainerName() {
- try {
- // Use docker ps with filter to find containers with
'spark-iceberg' in the name
- String command = "docker ps --filter name=spark-iceberg --format
{{.Names}}"
- def process = command.execute()
- process.waitFor()
- String output = process.in.text.trim()
-
- if (output) {
- // Get the first matching container
- String containerName = output.split('\n')[0].trim()
- if (containerName) {
- logger.info("Found spark-iceberg container:
${containerName}".toString())
- return containerName
- }
- }
+ private List<List<Object>> spark_sql(String sqlStr, boolean isOrder =
false) {
+ String cleanedSqlStr = sqlStr.replaceAll("\\s*;\\s*\$", "")
+ logger.info("Execute Spark JDBC SQL: ${cleanedSqlStr}".toString())
+ logger.info("Spark JDBC URL:
${context.getSparkIcebergJdbcUrl()}".toString())
+ return sql_impl(context.getSparkIcebergConnection(), cleanedSqlStr,
isOrder)
+ }
- logger.warn("No spark-iceberg container found via docker ps")
- return null
- } catch (Exception e) {
- logger.warn("Failed to get spark-iceberg container via docker ps:
${e.message}".toString())
- return null
+ private List spark_sql_multi(Object sqlStatements, boolean isOrder =
false) {
+ def statements = sqlStatements.toString().split(';').collect {
it.trim() }.findAll { it }
+
+ if (statements.isEmpty()) {
+ return []
}
+
+ logger.info("Execute Spark JDBC SQL statements via
${context.getSparkIcebergJdbcUrl()}: ${statements}".toString())
+ Connection sparkConn = context.getSparkIcebergConnection()
+ return statements.collect { statement -> sql_impl(sparkConn,
statement, isOrder) }
}
/**
- * Execute Spark SQL on the spark-iceberg container via docker exec.
+ * Execute Spark SQL on the Spark ThriftServer via Hive JDBC.
*
* Usage in test suite:
* spark_iceberg "CREATE TABLE demo.test_db.t1 (id INT) USING iceberg"
* spark_iceberg "INSERT INTO demo.test_db.t1 VALUES (1)"
* def result = spark_iceberg "SELECT * FROM demo.test_db.t1"
- *
- * The container name is found by querying 'docker ps --filter
name=spark-iceberg'
*/
- String spark_iceberg(String sqlStr, int timeoutSeconds = 120) {
- String containerName = getSparkIcebergContainerName()
- if (containerName == null) {
- throw new RuntimeException("spark-iceberg container not found.
Please ensure the container is running.")
- }
- String masterUrl = "spark://${containerName}:7077"
-
- // Escape double quotes in SQL string for shell command
- String escapedSql = sqlStr.replaceAll('"', '\\\\"')
-
- // Build docker exec command
- String command = """docker exec ${containerName} spark-sql --master
${masterUrl} --conf
spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions
-e "${escapedSql}" """
-
- logger.info("Executing Spark Iceberg SQL: ${sqlStr}".toString())
- logger.info("Container: ${containerName}".toString())
-
- try {
- String result = cmd(command, timeoutSeconds)
- logger.info("Spark Iceberg SQL result: ${result}".toString())
- return result
- } catch (Exception e) {
- logger.error("Spark Iceberg SQL failed: ${e.message}".toString())
- throw e
- }
+ List<List<Object>> spark_iceberg(String sqlStr, boolean isOrder = false) {
+ return spark_sql(sqlStr, isOrder)
}
/**
- * Execute multiple Spark SQL statements on the spark-iceberg container.
+ * Execute multiple Spark SQL statements on the Spark ThriftServer via
Hive JDBC.
* Statements are separated by semicolons.
+ * All statements are executed on one JDBC connection to reduce startup
overhead.
*
* Usage:
* spark_iceberg_multi '''
@@ -1684,49 +1652,40 @@ class Suite implements GroovyInterceptable {
* INSERT INTO demo.test_db.t1 VALUES (1);
* '''
*/
- List<String> spark_iceberg_multi(String sqlStatements, int timeoutSeconds
= 300) {
- // Split by semicolon and execute each statement
- def statements = sqlStatements.split(';').collect { it.trim()
}.findAll { it }
- def results = []
-
- for (stmt in statements) {
- if (stmt) {
- results << spark_iceberg(stmt, timeoutSeconds)
- }
- }
-
- return results
+ List spark_iceberg_multi(Object sqlStatements, boolean isOrder = false) {
+ return spark_sql_multi(sqlStatements, isOrder)
}
/**
- * Execute Spark SQL on the spark-iceberg container with Paimon extensions
enabled.
+ * Execute Spark SQL with the Paimon catalog on the Spark ThriftServer via
Hive JDBC.
*
* Usage in test suite:
* spark_paimon "CREATE TABLE paimon.test_db.t1 (id INT) USING paimon"
* spark_paimon "INSERT INTO paimon.test_db.t1 VALUES (1)"
* def result = spark_paimon "SELECT * FROM paimon.test_db.t1"
*/
- String spark_paimon(String sqlStr, int timeoutSeconds = 120) {
- String containerName = getSparkIcebergContainerName()
- if (containerName == null) {
- throw new RuntimeException("spark-iceberg container not found.
Please ensure the container is running.")
- }
- String masterUrl = "spark://${containerName}:7077"
-
- String escapedSql = sqlStr.replaceAll('"', '\\\\"')
- String command = """docker exec ${containerName} spark-sql --master
${masterUrl} --conf
spark.sql.extensions=org.apache.paimon.spark.extensions.PaimonSparkSessionExtensions
-e "${escapedSql}" """
+ List<List<Object>> spark_paimon(String sqlStr, boolean isOrder = false) {
+ return spark_sql(sqlStr, isOrder)
+ }
- logger.info("Executing Spark Paimon SQL: ${sqlStr}".toString())
- logger.info("Container: ${containerName}".toString())
+ /**
+ * Execute multiple Spark SQL statements with the Paimon catalog on the
Spark ThriftServer via Hive JDBC.
+ * Statements are separated by semicolons.
+ * All statements are executed on one JDBC connection to reduce startup
overhead.
+ *
+ * Usage:
+ * spark_paimon_multi '''
+ * CREATE DATABASE IF NOT EXISTS paimon.test_db;
+ * CREATE TABLE paimon.test_db.t1 (id INT) USING paimon;
+ * INSERT INTO paimon.test_db.t1 VALUES (1);
+ * '''
+ */
+ List spark_paimon_multi(Object sqlStatements, boolean isOrder = false) {
+ return spark_sql_multi(sqlStatements, isOrder)
+ }
- try {
- String result = cmd(command, timeoutSeconds)
- logger.info("Spark Paimon SQL result: ${result}".toString())
- return result
- } catch (Exception e) {
- logger.error("Spark Paimon SQL failed: ${e.message}".toString())
- throw e
- }
+ void assertSparkDorisResultEquals(List<List<Object>> sparkRows,
List<List<Object>> dorisRows) {
+ ResultUtils.assertSparkDorisResultEquals(sparkRows, dorisRows)
}
List<List<Object>> db2_docker(String sqlStr, boolean isOrder = false) {
@@ -1950,6 +1909,10 @@ class Suite implements GroovyInterceptable {
return quickTest(name.substring("order_qt_".length()), (args as
Object[])[0] as String, true)
} else if (name.startsWith("qe_")) {
return quickExecute(name.substring("qe_".length()), (args as
Object[])[0] as PreparedStatement)
+ } else if (name == "assertSparkDorisResultEquals") {
+ ResultUtils.assertSparkDorisResultEquals((args as Object[])[0] as
List<List<Object>>,
+ (args as Object[])[1] as List<List<Object>>)
+ return null
} else if (name.startsWith("assert") && name.length() >
"assert".length()) {
// delegate to junit Assertions dynamically
return Assertions."$name"(*args) // *args: spread-dot
diff --git
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/SuiteContext.groovy
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/SuiteContext.groovy
index 1fbdc2f45fc..dd360b353b8 100644
---
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/SuiteContext.groovy
+++
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/SuiteContext.groovy
@@ -53,6 +53,7 @@ class SuiteContext implements Closeable {
public final ThreadLocal<Connection> threadHive2DockerConn = new
ThreadLocal<>()
public final ThreadLocal<Connection> threadHive3DockerConn = new
ThreadLocal<>()
public final ThreadLocal<Connection> threadHiveRemoteConn = new
ThreadLocal<>()
+ public final ThreadLocal<Connection> threadSparkIcebergConn = new
ThreadLocal<>()
public final ThreadLocal<Connection> threadDB2DockerConn = new
ThreadLocal<>()
private final ThreadLocal<Syncer> syncer = new ThreadLocal<>()
public final Config config
@@ -235,6 +236,15 @@ class SuiteContext implements Closeable {
return threadConn
}
+ Connection getSparkIcebergConnection() {
+ def threadConn = threadSparkIcebergConn.get()
+ if (threadConn == null) {
+ threadConn = getConnectionBySparkIcebergConfig()
+ threadSparkIcebergConn.set(threadConn)
+ }
+ return threadConn
+ }
+
Connection getDB2DockerConnection() {
def threadConn = threadDB2DockerConn.get()
if (threadConn == null) {
@@ -310,6 +320,21 @@ class SuiteContext implements Closeable {
return DriverManager.getConnection(hiveJdbcUrl, hiveJdbcUser,
hiveJdbcPassword)
}
+ Connection getConnectionBySparkIcebergConfig() {
+ Class.forName("org.apache.hive.jdbc.HiveDriver");
+ String sparkJdbcUser = "hadoop"
+ String sparkJdbcPassword = "hadoop"
+ String sparkJdbcUrl = getSparkIcebergJdbcUrl()
+ log.info("Create Spark Iceberg JDBC connection to
${sparkJdbcUrl}".toString())
+ return DriverManager.getConnection(sparkJdbcUrl, sparkJdbcUser,
sparkJdbcPassword)
+ }
+
+ String getSparkIcebergJdbcUrl() {
+ String sparkHost = config.otherConfigs.get("externalEnvIp")
+ String sparkPort =
config.otherConfigs.get("iceberg_spark_thrift_port") ?: "11000"
+ return "jdbc:hive2://${sparkHost}:${sparkPort}/default"
+ }
+
Connection getConnectionByDB2DockerConfig() {
Class.forName("com.ibm.db2.jcc.DB2Driver");
String db2Host = config.otherConfigs.get("externalEnvIp")
@@ -612,6 +637,16 @@ class SuiteContext implements Closeable {
log.warn("Close connection failed", t)
}
}
+
+ Connection spark_iceberg_conn = threadSparkIcebergConn.get()
+ if (spark_iceberg_conn != null) {
+ threadSparkIcebergConn.remove()
+ try {
+ spark_iceberg_conn.close()
+ } catch (Throwable t) {
+ log.warn("Close connection failed", t)
+ }
+ }
}
diff --git
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/util/JdbcUtils.groovy
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/util/JdbcUtils.groovy
index bac8a2087d5..c79303c6f1e 100644
---
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/util/JdbcUtils.groovy
+++
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/util/JdbcUtils.groovy
@@ -24,6 +24,7 @@ import java.sql.Connection
import java.sql.PreparedStatement
import java.sql.ResultSet
import java.sql.ResultSetMetaData
+import java.sql.SQLFeatureNotSupportedException
import java.sql.Types
import org.slf4j.Logger
import org.slf4j.LoggerFactory
@@ -126,8 +127,12 @@ class JdbcUtils {
for (int i = 1; i <= columnCount; ++i) {
int jdbcType = resultSet.metaData.getColumnType(i)
if (isBinaryJdbcType(jdbcType)) {
- byte[] bytes = resultSet.getBytes(i)
- row.add(bytes == null ? null : bytesToHex(bytes))
+ try {
+ byte[] bytes = resultSet.getBytes(i)
+ row.add(bytes == null ? null : bytesToHex(bytes))
+ } catch (SQLFeatureNotSupportedException ignored) {
+ row.add(resultSet.getObject(i))
+ }
} else {
row.add(resultSet.getObject(i))
}
diff --git
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/util/ResultUtils.groovy
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/util/ResultUtils.groovy
new file mode 100644
index 00000000000..897377b8e9d
--- /dev/null
+++
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/util/ResultUtils.groovy
@@ -0,0 +1,756 @@
+// 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.regression.util
+
+import groovy.transform.CompileStatic
+import org.junit.Assert
+
+import java.math.BigDecimal
+import java.math.BigInteger
+import java.nio.ByteBuffer
+import java.sql.Blob
+import java.sql.Clob
+import java.sql.Struct
+import java.sql.Time
+import java.sql.Timestamp
+import java.time.temporal.TemporalAccessor
+import java.util.regex.Pattern
+
+@CompileStatic
+class ResultUtils {
+ private static final Object PARSE_FAILED = new Object()
+ private static final double NUMERIC_RELATIVE_TOLERANCE = 1.0E-8D
+ private static final Pattern NUMBER_PATTERN = Pattern.compile(
+ "[-+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][-+]?\\d+)?")
+ private static final Pattern PREFIXED_HEX_PATTERN =
Pattern.compile("(?i)0x[0-9a-f]*")
+ private static final Pattern BARE_HEX_PATTERN =
Pattern.compile("(?i)[0-9a-f]*")
+ private static final Pattern DATE_PATTERN =
Pattern.compile("\\d{4}-\\d{2}-\\d{2}")
+ private static final Pattern TIME_PATTERN = Pattern.compile(
+ "(\\d{2}:\\d{2})(?::(\\d{2})(?:\\.(\\d{1,9}))?)?")
+ private static final Pattern DATETIME_PATTERN = Pattern.compile(
+ "(\\d{4}-\\d{2}-\\d{2})[T
](\\d{2}:\\d{2})(?::(\\d{2})(?:\\.(\\d{1,9}))?)?")
+ private static final Pattern UNICODE_ESCAPE_PATTERN =
Pattern.compile("[0-9a-fA-F]{4}")
+
+ static List<List<Object>> normalizeRows(List<List<Object>> rows) {
+ List<List<Object>> normalizedRows = new ArrayList<>()
+ for (List<Object> row : rows) {
+ List<Object> normalizedRow = new ArrayList<>()
+ for (Object value : row) {
+ normalizedRow.add(normalizeValue(value))
+ }
+ normalizedRows.add(normalizedRow)
+ }
+ return normalizedRows
+ }
+
+ static void assertSparkDorisResultEquals(List<List<Object>> sparkRows,
List<List<Object>> dorisRows) {
+ List<List<Object>> normalizedSparkRows = normalizeRows(sparkRows)
+ List<List<Object>> normalizedDorisRows = normalizeRows(dorisRows)
+ Assert.assertEquals("Spark and Doris result row count mismatch",
+ normalizedSparkRows.size(), normalizedDorisRows.size())
+
+ for (int rowIndex = 0; rowIndex < normalizedSparkRows.size();
rowIndex++) {
+ List<Object> sparkRow = normalizedSparkRows.get(rowIndex)
+ List<Object> dorisRow = normalizedDorisRows.get(rowIndex)
+ Assert.assertEquals("Spark and Doris result column count mismatch
at row " + (rowIndex + 1),
+ sparkRow.size(), dorisRow.size())
+ for (int columnIndex = 0; columnIndex < sparkRow.size();
columnIndex++) {
+ Object sparkValue = sparkRow.get(columnIndex)
+ Object dorisValue = dorisRow.get(columnIndex)
+ if (!valueEquals(sparkValue, dorisValue)) {
+ Assert.fail("Spark/Doris result mismatch at row " +
(rowIndex + 1)
+ + ", column " + (columnIndex + 1)
+ + "\nSpark raw : " +
valueToString(sparkRows.get(rowIndex).get(columnIndex))
+ + "\nDoris raw : " +
valueToString(dorisRows.get(rowIndex).get(columnIndex))
+ + "\nSpark normalized: " +
valueToString(sparkValue)
+ + "\nDoris normalized: " +
valueToString(dorisValue))
+ }
+ }
+ }
+ }
+
+ static Object normalizeValue(Object value) {
+ if (value == null) {
+ return null
+ }
+ if (value instanceof byte[]) {
+ return bytesToHex((byte[]) value)
+ }
+ if (value instanceof ByteBuffer) {
+ return bytesToHex(byteBufferToBytes((ByteBuffer) value))
+ }
+ if (value instanceof BigDecimal) {
+ return normalizeDecimal((BigDecimal) value)
+ }
+ if (value instanceof BigInteger
+ || value instanceof Byte
+ || value instanceof Short
+ || value instanceof Integer
+ || value instanceof Long) {
+ return normalizeDecimal(new BigDecimal(value.toString()))
+ }
+ if (value instanceof Float || value instanceof Double) {
+ Double doubleValue = ((Number) value).doubleValue()
+ if (doubleValue.isNaN() || doubleValue.isInfinite()) {
+ return doubleValue.toString().toLowerCase(Locale.ROOT)
+ }
+ return new ApproximateNumber(normalizeDecimal(new
BigDecimal(value.toString())))
+ }
+ if (value instanceof Boolean) {
+ return value
+ }
+ if (value instanceof java.sql.Array) {
+ try {
+ return normalizeValue(((java.sql.Array) value).getArray())
+ } catch (Exception ignored) {
+ return value.toString()
+ }
+ }
+ if (value instanceof Struct) {
+ try {
+ return normalizeValue(Arrays.asList(((Struct)
value).getAttributes()))
+ } catch (Exception ignored) {
+ return value.toString()
+ }
+ }
+ if (value instanceof Blob) {
+ Blob blob = (Blob) value
+ try {
+ return bytesToHex(blob.getBytes(1L, (int) blob.length()))
+ } catch (Exception ignored) {
+ return value.toString()
+ }
+ }
+ if (value instanceof Clob) {
+ Clob clob = (Clob) value
+ try {
+ return normalizeValue(clob.getSubString(1L, (int)
clob.length()))
+ } catch (Exception ignored) {
+ return value.toString()
+ }
+ }
+ if (value instanceof java.sql.Date
+ || value instanceof Time
+ || value instanceof Timestamp
+ || value instanceof java.util.Date
+ || value instanceof Calendar
+ || value instanceof TemporalAccessor) {
+ return normalizeString(value.toString())
+ }
+ if (value instanceof Map) {
+ TreeMap<String, Object> normalizedMap = new TreeMap<>()
+ for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {
+ normalizedMap.put(canonicalKey(entry.getKey()),
normalizeValue(entry.getValue()))
+ }
+ return normalizedMap
+ }
+ if (value instanceof Collection) {
+ List<Object> normalizedList = new ArrayList<>()
+ for (Object item : (Collection<?>) value) {
+ normalizedList.add(normalizeValue(item))
+ }
+ return normalizedList
+ }
+ if (value.getClass().isArray()) {
+ List<Object> normalizedList = new ArrayList<>()
+ int length = java.lang.reflect.Array.getLength(value)
+ for (int i = 0; i < length; i++) {
+
normalizedList.add(normalizeValue(java.lang.reflect.Array.get(value, i)))
+ }
+ return normalizedList
+ }
+ if (value instanceof CharSequence) {
+ return normalizeString(value.toString())
+ }
+ return normalizeString(value.toString())
+ }
+
+ private static Object normalizeString(String raw) {
+ String text = raw.trim()
+ Object parsed = parseComplexValue(text)
+ if (parsed != PARSE_FAILED) {
+ return normalizeValue(parsed)
+ }
+
+ parsed = parseQuotedStringValue(text)
+ if (parsed != PARSE_FAILED) {
+ return normalizeValue(parsed)
+ }
+
+ if (PREFIXED_HEX_PATTERN.matcher(text).matches()) {
+ return normalizePrefixedHex(text)
+ }
+
+ String normalizedTemporal = normalizeTemporal(text)
+ if (normalizedTemporal != null) {
+ return normalizedTemporal
+ }
+
+ String lowerText = text.toLowerCase(Locale.ROOT)
+ if (lowerText == "nan"
+ || lowerText == "-nan"
+ || lowerText == "inf"
+ || lowerText == "+inf"
+ || lowerText == "-inf"
+ || lowerText == "infinity"
+ || lowerText == "+infinity"
+ || lowerText == "-infinity") {
+ return lowerText
+ }
+ return raw
+ }
+
+ private static Object parseQuotedStringValue(String text) {
+ if (text.length() < 2 || text.charAt(0) != ('"' as char)
+ || text.charAt(text.length() - 1) != ('"' as char)) {
+ return PARSE_FAILED
+ }
+ try {
+ return new ComplexValueParser(text).parse()
+ } catch (RuntimeException ignored) {
+ return PARSE_FAILED
+ }
+ }
+
+ private static Object parseComplexValue(String text) {
+ if (text.length() < 2) {
+ return PARSE_FAILED
+ }
+ char first = text.charAt(0)
+ char last = text.charAt(text.length() - 1)
+ if (!((first == '[' as char && last == ']' as char)
+ || (first == '{' as char && last == '}' as char))) {
+ return PARSE_FAILED
+ }
+ try {
+ return new ComplexValueParser(text).parse()
+ } catch (RuntimeException ignored) {
+ return PARSE_FAILED
+ }
+ }
+
+ private static String normalizeTemporal(String text) {
+ if (DATE_PATTERN.matcher(text).matches()) {
+ return text
+ }
+
+ def datetimeMatcher = DATETIME_PATTERN.matcher(text)
+ if (datetimeMatcher.matches()) {
+ return datetimeMatcher.group(1) + " " + normalizeTime(
+ datetimeMatcher.group(2), datetimeMatcher.group(3),
datetimeMatcher.group(4))
+ }
+
+ def timeMatcher = TIME_PATTERN.matcher(text)
+ if (timeMatcher.matches()) {
+ return normalizeTime(timeMatcher.group(1), timeMatcher.group(2),
timeMatcher.group(3))
+ }
+
+ return null
+ }
+
+ private static String normalizeTime(String hourMinute, String secondPart,
String fractionPart) {
+ String second = secondPart == null ? "00" : secondPart
+ String fraction = fractionPart
+ if (fraction != null) {
+ fraction = fraction.replaceAll("0+\$", "")
+ }
+ String suffix = fraction == null || fraction.isEmpty() ? "" : "." +
fraction
+ return hourMinute + ":" + second + suffix
+ }
+
+ private static String normalizePrefixedHex(String text) {
+ return "0x" + text.substring(2).toUpperCase(Locale.ROOT)
+ }
+
+ private static String bytesToHex(byte[] bytes) {
+ StringBuilder builder = new StringBuilder(2 + bytes.length * 2)
+ builder.append("0x")
+ for (byte b : bytes) {
+ builder.append(String.format("%02X", b & 0xFF))
+ }
+ return builder.toString()
+ }
+
+ private static byte[] byteBufferToBytes(ByteBuffer buffer) {
+ ByteBuffer copy = buffer.asReadOnlyBuffer()
+ byte[] bytes = new byte[copy.remaining()]
+ copy.get(bytes)
+ return bytes
+ }
+
+ private static BigDecimal normalizeDecimal(BigDecimal decimal) {
+ BigDecimal normalized = decimal.stripTrailingZeros()
+ if (normalized.compareTo(BigDecimal.ZERO) == 0) {
+ return BigDecimal.ZERO
+ }
+ return normalized
+ }
+
+ private static boolean valueEquals(Object left, Object right) {
+ if (left == null || right == null) {
+ return left == right
+ }
+ if (left.equals(right)) {
+ return true
+ }
+ if (left instanceof Map && right instanceof Map) {
+ return mapEquals((Map<?, ?>) left, (Map<?, ?>) right)
+ }
+ if (left instanceof List && right instanceof List) {
+ List<?> leftList = (List<?>) left
+ List<?> rightList = (List<?>) right
+ if (leftList.size() != rightList.size()) {
+ return false
+ }
+ for (int i = 0; i < leftList.size(); i++) {
+ if (!valueEquals(leftList.get(i), rightList.get(i))) {
+ return false
+ }
+ }
+ return true
+ }
+
+ if (hexEquals(left, right)) {
+ return true
+ }
+
+ if (left instanceof Boolean || right instanceof Boolean) {
+ Boolean leftBoolean = asBoolean(left)
+ Boolean rightBoolean = asBoolean(right)
+ if (leftBoolean != null && rightBoolean != null) {
+ return leftBoolean == rightBoolean
+ }
+ }
+
+ BigDecimal leftDecimal = asDecimal(left)
+ BigDecimal rightDecimal = asDecimal(right)
+ if (leftDecimal != null && rightDecimal != null) {
+ return numericEquals(left, right, leftDecimal, rightDecimal)
+ }
+ return left.equals(right)
+ }
+
+ private static boolean mapEquals(Map<?, ?> leftMap, Map<?, ?> rightMap) {
+ if (leftMap.size() != rightMap.size()) {
+ return false
+ }
+ if (leftMap.keySet().equals(rightMap.keySet())) {
+ for (Object key : leftMap.keySet()) {
+ if (!valueEquals(leftMap.get(key), rightMap.get(key))) {
+ return false
+ }
+ }
+ return true
+ }
+
+ Set<Object> matchedRightKeys = new HashSet<>()
+ for (Object leftKey : leftMap.keySet()) {
+ Object matchedRightKey = findMatchedMapKey(leftKey,
leftMap.get(leftKey), rightMap, matchedRightKeys)
+ if (matchedRightKey == null) {
+ return false
+ }
+ matchedRightKeys.add(matchedRightKey)
+ }
+ return true
+ }
+
+ private static Object findMatchedMapKey(Object leftKey, Object leftValue,
Map<?, ?> rightMap,
+ Set<Object> matchedRightKeys) {
+ for (Object rightKey : rightMap.keySet()) {
+ if (matchedRightKeys.contains(rightKey)
+ || !mapKeyEquals(leftKey, rightKey)
+ || !valueEquals(leftValue, rightMap.get(rightKey))) {
+ continue
+ }
+ return rightKey
+ }
+ return null
+ }
+
+ private static boolean mapKeyEquals(Object leftKey, Object rightKey) {
+ if (leftKey == null || rightKey == null) {
+ return leftKey == rightKey
+ }
+ if (leftKey.equals(rightKey)) {
+ return true
+ }
+ Boolean leftBoolean = booleanEquivalentMapKey(leftKey)
+ Boolean rightBoolean = booleanEquivalentMapKey(rightKey)
+ return leftBoolean != null && rightBoolean != null && leftBoolean ==
rightBoolean
+ }
+
+ private static Boolean booleanEquivalentMapKey(Object key) {
+ if (key instanceof CharSequence) {
+ String text = key.toString()
+ if (text == "bool:true") {
+ return true
+ }
+ if (text == "bool:false") {
+ return false
+ }
+ if (text.startsWith("num:")) {
+ return booleanFromDecimalText(text.substring("num:".length()))
+ }
+ return null
+ }
+ return asBoolean(key)
+ }
+
+ private static Boolean booleanFromDecimalText(String text) {
+ try {
+ BigDecimal decimal = normalizeDecimal(new BigDecimal(text))
+ if (decimal.compareTo(BigDecimal.ZERO) == 0) {
+ return false
+ }
+ if (decimal.compareTo(BigDecimal.ONE) == 0) {
+ return true
+ }
+ return null
+ } catch (NumberFormatException ignored) {
+ return null
+ }
+ }
+
+ private static boolean hexEquals(Object left, Object right) {
+ String leftHex = asHex(left, right)
+ String rightHex = asHex(right, left)
+ return leftHex != null && rightHex != null && leftHex == rightHex
+ }
+
+ private static String asHex(Object value, Object counterpart) {
+ if (!(value instanceof CharSequence) && !(value instanceof
BigDecimal)) {
+ return null
+ }
+ String text = value instanceof BigDecimal ? ((BigDecimal)
value).toPlainString() : value.toString().trim()
+ if (PREFIXED_HEX_PATTERN.matcher(text).matches()) {
+ return text.substring(2).toUpperCase(Locale.ROOT)
+ }
+ if (!(counterpart instanceof CharSequence)
+ ||
!PREFIXED_HEX_PATTERN.matcher(counterpart.toString().trim()).matches()
+ || text.length() % 2 != 0
+ || !BARE_HEX_PATTERN.matcher(text).matches()) {
+ return null
+ }
+ return text.toUpperCase(Locale.ROOT)
+ }
+
+ private static Boolean asBoolean(Object value) {
+ if (value instanceof Boolean) {
+ return (Boolean) value
+ }
+ BigDecimal decimal = asDecimal(value)
+ if (decimal != null) {
+ if (decimal.compareTo(BigDecimal.ZERO) == 0) {
+ return false
+ }
+ if (decimal.compareTo(BigDecimal.ONE) == 0) {
+ return true
+ }
+ return null
+ }
+ if (value instanceof CharSequence) {
+ String text = value.toString().trim().toLowerCase(Locale.ROOT)
+ if (text == "true") {
+ return true
+ }
+ if (text == "false") {
+ return false
+ }
+ }
+ return null
+ }
+
+ private static BigDecimal asDecimal(Object value) {
+ if (value instanceof BigDecimal) {
+ return (BigDecimal) value
+ }
+ if (value instanceof BigInteger
+ || value instanceof Byte
+ || value instanceof Short
+ || value instanceof Integer
+ || value instanceof Long) {
+ return normalizeDecimal(new BigDecimal(value.toString()))
+ }
+ if (value instanceof Float || value instanceof Double) {
+ Double doubleValue = ((Number) value).doubleValue()
+ if (doubleValue.isNaN() || doubleValue.isInfinite()) {
+ return null
+ }
+ return normalizeDecimal(new BigDecimal(value.toString()))
+ }
+ if (value instanceof ApproximateNumber) {
+ return ((ApproximateNumber) value).decimal
+ }
+ if (value instanceof CharSequence) {
+ String text = value.toString().trim()
+ if (NUMBER_PATTERN.matcher(text).matches()) {
+ return normalizeDecimal(new BigDecimal(text))
+ }
+ }
+ return null
+ }
+
+ private static boolean numericEquals(Object left, Object right, BigDecimal
leftDecimal, BigDecimal rightDecimal) {
+ if (left instanceof CharSequence && right instanceof CharSequence) {
+ return false
+ }
+ if (left instanceof ApproximateNumber || right instanceof
ApproximateNumber
+ || left instanceof Float || left instanceof Double
+ || right instanceof Float || right instanceof Double) {
+ return approximateDecimalEquals(leftDecimal, rightDecimal)
+ }
+ return leftDecimal.compareTo(rightDecimal) == 0
+ }
+
+ private static boolean approximateDecimalEquals(BigDecimal left,
BigDecimal right) {
+ if (left.compareTo(right) == 0) {
+ return true
+ }
+ double leftDouble = left.doubleValue()
+ double rightDouble = right.doubleValue()
+ if (Double.isInfinite(leftDouble) || Double.isInfinite(rightDouble)
+ || Double.isNaN(leftDouble) || Double.isNaN(rightDouble)) {
+ return false
+ }
+ double diff = Math.abs(leftDouble - rightDouble)
+ double base = Math.max(1.0D, Math.max(Math.abs(leftDouble),
Math.abs(rightDouble)))
+ return diff / base <= NUMERIC_RELATIVE_TOLERANCE
+ }
+
+ private static String canonicalKey(Object key) {
+ Object normalizedKey = normalizeValue(key)
+ if (normalizedKey instanceof Boolean) {
+ return ((Boolean) normalizedKey) ? "bool:true" : "bool:false"
+ }
+ if (!(normalizedKey instanceof CharSequence)) {
+ BigDecimal decimal = asDecimal(normalizedKey)
+ if (decimal != null) {
+ return "num:" + decimal.toPlainString()
+ }
+ }
+ if (normalizedKey instanceof Map || normalizedKey instanceof List) {
+ return "complex:" + valueToString(normalizedKey)
+ }
+ return "str:" + normalizedKey.toString()
+ }
+
+ private static String valueToString(Object value) {
+ if (value == null) {
+ return "null"
+ }
+ if (value instanceof BigDecimal) {
+ return ((BigDecimal) value).toPlainString()
+ }
+ if (value instanceof ApproximateNumber) {
+ return ((ApproximateNumber) value).decimal.toPlainString()
+ }
+ return value.toString()
+ }
+
+ private static final class ApproximateNumber {
+ private final BigDecimal decimal
+
+ private ApproximateNumber(BigDecimal decimal) {
+ this.decimal = decimal
+ }
+
+ @Override
+ String toString() {
+ return decimal.toPlainString()
+ }
+ }
+
+ private static final class ComplexValueParser {
+ private final String text
+ private int position = 0
+
+ ComplexValueParser(String text) {
+ this.text = text
+ }
+
+ Object parse() {
+ Object value = parseValue(false)
+ skipWhitespace()
+ if (position != text.length()) {
+ throw new IllegalArgumentException("Unexpected trailing
content")
+ }
+ return value
+ }
+
+ private Object parseValue(boolean stopAtColon) {
+ skipWhitespace()
+ if (position >= text.length()) {
+ throw new IllegalArgumentException("Unexpected end of input")
+ }
+ char ch = text.charAt(position)
+ if (ch == '[' as char) {
+ return parseArray()
+ }
+ if (ch == '{' as char) {
+ return parseObject()
+ }
+ if (ch == '"' as char) {
+ return parseQuotedString()
+ }
+ return parseToken(stopAtColon)
+ }
+
+ private List<Object> parseArray() {
+ position++
+ List<Object> values = new ArrayList<>()
+ skipWhitespace()
+ if (consume(']' as char)) {
+ return values
+ }
+ while (true) {
+ values.add(parseValue(false))
+ skipWhitespace()
+ if (consume(',' as char)) {
+ continue
+ }
+ if (consume(']' as char)) {
+ return values
+ }
+ throw new IllegalArgumentException("Expected ',' or ']'")
+ }
+ }
+
+ private Map<Object, Object> parseObject() {
+ position++
+ Map<Object, Object> values = new LinkedHashMap<>()
+ skipWhitespace()
+ if (consume('}' as char)) {
+ return values
+ }
+ while (true) {
+ Object key = parseValue(true)
+ skipWhitespace()
+ if (!consume(':' as char)) {
+ throw new IllegalArgumentException("Expected ':'")
+ }
+ Object value = parseValue(false)
+ values.put(key, value)
+ skipWhitespace()
+ if (consume(',' as char)) {
+ continue
+ }
+ if (consume('}' as char)) {
+ return values
+ }
+ throw new IllegalArgumentException("Expected ',' or '}'")
+ }
+ }
+
+ private String parseQuotedString() {
+ position++
+ StringBuilder builder = new StringBuilder()
+ while (position < text.length()) {
+ char ch = text.charAt(position++)
+ if (ch == '"' as char) {
+ return builder.toString()
+ }
+ if (ch == '\\' as char && position < text.length()) {
+ char escaped = text.charAt(position++)
+ switch (escaped) {
+ case '"' as char:
+ case '\\' as char:
+ case '/' as char:
+ builder.append(escaped)
+ break
+ case 'b' as char:
+ builder.append('\b' as char)
+ break
+ case 'f' as char:
+ builder.append('\f' as char)
+ break
+ case 'n' as char:
+ builder.append('\n' as char)
+ break
+ case 'r' as char:
+ builder.append('\r' as char)
+ break
+ case 't' as char:
+ builder.append('\t' as char)
+ break
+ case 'u' as char:
+ if (position + 4 <= text.length()) {
+ String hex = text.substring(position, position
+ 4)
+ if
(UNICODE_ESCAPE_PATTERN.matcher(hex).matches()) {
+ builder.append((char)
Integer.parseInt(hex, 16))
+ position += 4
+ break
+ }
+ }
+ builder.append(escaped)
+ break
+ default:
+ builder.append(escaped)
+ break
+ }
+ } else {
+ builder.append(ch)
+ }
+ }
+ throw new IllegalArgumentException("Unclosed quoted string")
+ }
+
+ private Object parseToken(boolean stopAtColon) {
+ int start = position
+ while (position < text.length()) {
+ char ch = text.charAt(position)
+ if (ch == ',' as char || ch == ']' as char || ch == '}' as char
+ || (stopAtColon && ch == ':' as char)) {
+ break
+ }
+ position++
+ }
+ String token = text.substring(start, position).trim()
+ if (token.isEmpty()) {
+ return ""
+ }
+ String lowerToken = token.toLowerCase(Locale.ROOT)
+ if (lowerToken == "null") {
+ return null
+ }
+ if (lowerToken == "true") {
+ return true
+ }
+ if (lowerToken == "false") {
+ return false
+ }
+ if (NUMBER_PATTERN.matcher(token).matches()) {
+ return normalizeDecimal(new BigDecimal(token))
+ }
+ return token
+ }
+
+ private void skipWhitespace() {
+ while (position < text.length() &&
Character.isWhitespace(text.charAt(position))) {
+ position++
+ }
+ }
+
+ private boolean consume(char expected) {
+ if (position < text.length() && text.charAt(position) == expected)
{
+ position++
+ return true
+ }
+ return false
+ }
+ }
+}
diff --git
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_spark_doris_consistency_demo.groovy
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_spark_doris_consistency_demo.groovy
new file mode 100644
index 00000000000..3ce5c8ebef3
--- /dev/null
+++
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_spark_doris_consistency_demo.groovy
@@ -0,0 +1,301 @@
+// 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.
+
+suite("test_iceberg_spark_doris_consistency_demo", "p0,external,iceberg") {
+ String enabled = context.config.otherConfigs.get("enableIcebergTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("disable iceberg test.")
+ return
+ }
+
+ String catalogName = "test_iceberg_spark_doris_consistency_demo"
+ String dbName = "iceberg_spark_doris_consistency_demo_db"
+ String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+
+ def expectedBasicRows = [
+ [1, "alice", 10],
+ [2, "bob", 20],
+ [3, "cindy", null],
+ [4, "doris", 40],
+ [5, "edge", 0]
+ ]
+ def expectedAggRows = [[5L, 70L]]
+
+ // Example: execute multiple Spark Iceberg statements in one JDBC
connection.
+ spark_iceberg_multi """
+ SET spark.sql.binaryOutputStyle=HEX;
+ SET spark.sql.timestampType=TIMESTAMP_NTZ;
+ CREATE DATABASE IF NOT EXISTS demo.${dbName};
+ DROP TABLE IF EXISTS demo.${dbName}.spark_written_iceberg_demo;
+ CREATE TABLE demo.${dbName}.spark_written_iceberg_demo (
+ id INT,
+ name STRING,
+ score INT,
+ string_col STRING,
+ bool_col BOOLEAN,
+ int_col INT,
+ bigint_col BIGINT,
+ float_col FLOAT,
+ double_col DOUBLE,
+ decimal_small_col DECIMAL(9, 2),
+ decimal_col DECIMAL(18, 6),
+ decimal_wide_col DECIMAL(38, 12),
+ date_col DATE,
+ timestamp_col TIMESTAMP,
+ binary_col BINARY,
+ array_col ARRAY<INT>,
+ array_string_col ARRAY<STRING>,
+ array_bool_col ARRAY<BOOLEAN>,
+ array_binary_col ARRAY<BINARY>,
+ array_decimal_col ARRAY<DECIMAL(18, 6)>,
+ array_date_col ARRAY<DATE>,
+ array_timestamp_col ARRAY<TIMESTAMP>,
+ map_col MAP<STRING, INT>,
+ map_int_string_col MAP<INT, STRING>,
+ map_bool_col MAP<BOOLEAN, BOOLEAN>,
+ map_binary_col MAP<STRING, BINARY>,
+ map_decimal_col MAP<DECIMAL(8, 2), DECIMAL(8, 2)>,
+ struct_col STRUCT<city:STRING, zip:INT>,
+ struct_all_col STRUCT<string_field:STRING, bool_field:BOOLEAN,
int_field:INT,
+ bigint_field:BIGINT, float_field:FLOAT,
double_field:DOUBLE,
+ binary_field:BINARY,
decimal_field:DECIMAL(18, 6), date_field:DATE,
+ timestamp_field:TIMESTAMP>,
+ nested_col MAP<STRING, ARRAY<STRUCT<score:INT, label:STRING>>>
+ ) USING iceberg;
+ INSERT INTO demo.${dbName}.spark_written_iceberg_demo VALUES
+ (
+ 1, 'alice', 10, 'alice-string', true,
+ 700, 7000000000,
+ CAST(1.25 AS FLOAT), CAST(10.125 AS DOUBLE),
+ CAST(12.34 AS DECIMAL(9, 2)),
+ CAST(12345.678900 AS DECIMAL(18, 6)),
+ CAST(123456789.012345678901 AS DECIMAL(38, 12)),
+ DATE '2024-03-20', TIMESTAMP '2024-03-20 12:00:00.123456',
+ CAST('alice-bin' AS BINARY),
+ ARRAY(1, 2, 3), ARRAY(CAST(NULL AS STRING), 'a', 'b'),
+ ARRAY(true, false),
+ ARRAY(CAST('alice-array-bin' AS BINARY), CAST(NULL AS BINARY)),
+ ARRAY(CAST(1.250000 AS DECIMAL(18, 6)), CAST(2.500000 AS
DECIMAL(18, 6))),
+ ARRAY(DATE '2024-03-20', DATE '2024-03-21'),
+ ARRAY(TIMESTAMP '2024-03-20 12:00:00.123456'),
+ MAP('math', 90, 'eng', 95),
+ MAP(1, 'one', 2, 'two'),
+ MAP(true, false, false, true),
+ MAP('payload', CAST('alice-map-bin' AS BINARY)),
+ MAP(CAST(1.25 AS DECIMAL(8, 2)), CAST(2.50 AS DECIMAL(8, 2)),
+ CAST(3.75 AS DECIMAL(8, 2)), CAST(4.00 AS DECIMAL(8, 2))),
+ NAMED_STRUCT('city', 'Beijing', 'zip', 100000),
+ NAMED_STRUCT('string_field', 'alice', 'bool_field', true,
'int_field', 700,
+ 'bigint_field', 7000000000, 'float_field', CAST(1.25 AS
FLOAT),
+ 'double_field', CAST(10.125 AS DOUBLE),
+ 'binary_field', CAST('alice-struct-bin' AS BINARY),
+ 'decimal_field', CAST(12345.678900 AS DECIMAL(18, 6)),
+ 'date_field', DATE '2024-03-20',
+ 'timestamp_field', TIMESTAMP '2024-03-20 12:00:00.123456'),
+ MAP('term', ARRAY(NAMED_STRUCT('score', 90, 'label', 'good')))
+ ),
+ (
+ 2, 'bob', 20, 'bob-string', false,
+ -800, -8000000000,
+ CAST(-2.5 AS FLOAT), CAST(-20.25 AS DOUBLE),
+ CAST(-98.76 AS DECIMAL(9, 2)),
+ CAST(-9876.543210 AS DECIMAL(18, 6)),
+ CAST(-987654321.012345678901 AS DECIMAL(38, 12)),
+ DATE '2024-03-21', TIMESTAMP '2024-03-21 13:01:02.654321',
+ CAST('bob-bin' AS BINARY),
+ ARRAY(4, 5), ARRAY('x', CAST(NULL AS STRING), 'z'),
+ ARRAY(false, true),
+ ARRAY(CAST('bob-array-bin' AS BINARY), CAST('bob-array-bin-2'
AS BINARY)),
+ ARRAY(CAST(-3.750000 AS DECIMAL(18, 6)), CAST(4.125000 AS
DECIMAL(18, 6))),
+ ARRAY(DATE '2024-03-21'),
+ ARRAY(TIMESTAMP '2024-03-21 13:01:02.654321',
+ TIMESTAMP '2024-03-21 13:01:03.000000'),
+ MAP('math', 80, 'eng', 85),
+ MAP(3, 'three', 4, 'four'),
+ MAP(true, true, false, false),
+ MAP('payload', CAST('bob-map-bin' AS BINARY)),
+ MAP(CAST(-1.25 AS DECIMAL(8, 2)), CAST(-2.50 AS DECIMAL(8,
2))),
+ NAMED_STRUCT('city', 'Shanghai', 'zip', 200000),
+ NAMED_STRUCT('string_field', 'bob', 'bool_field', false,
'int_field', -800,
+ 'bigint_field', -8000000000, 'float_field', CAST(-2.5 AS
FLOAT),
+ 'double_field', CAST(-20.25 AS DOUBLE),
+ 'binary_field', CAST('bob-struct-bin' AS BINARY),
+ 'decimal_field', CAST(-9876.543210 AS DECIMAL(18, 6)),
+ 'date_field', DATE '2024-03-21',
+ 'timestamp_field', TIMESTAMP '2024-03-21 13:01:02.654321'),
+ MAP('term', ARRAY(
+ NAMED_STRUCT('score', 80, 'label', 'pass'),
+ NAMED_STRUCT('score', 85, 'label', 'better')
+ ))
+ ),
+ (
+ 3, 'cindy', NULL, NULL, NULL,
+ NULL, NULL,
+ NULL, NULL, NULL, NULL, NULL,
+ NULL, NULL, NULL,
+ ARRAY(CAST(NULL AS INT), 6), ARRAY(CAST(NULL AS STRING)),
+ ARRAY(CAST(NULL AS BOOLEAN), true),
+ ARRAY(CAST(NULL AS BINARY)),
+ ARRAY(CAST(NULL AS DECIMAL(18, 6))),
+ ARRAY(CAST(NULL AS DATE)),
+ ARRAY(CAST(NULL AS TIMESTAMP)),
+ MAP('science', CAST(NULL AS INT)),
+ MAP(5, CAST(NULL AS STRING)),
+ MAP(false, CAST(NULL AS BOOLEAN)),
+ MAP('payload', CAST(NULL AS BINARY)),
+ MAP(CAST(5.25 AS DECIMAL(8, 2)), CAST(NULL AS DECIMAL(8, 2))),
+ NAMED_STRUCT('city', CAST(NULL AS STRING), 'zip', CAST(NULL AS
INT)),
+ NAMED_STRUCT('string_field', CAST(NULL AS STRING),
'bool_field', CAST(NULL AS BOOLEAN),
+ 'int_field', CAST(NULL AS INT),
+ 'bigint_field', CAST(NULL AS BIGINT), 'float_field',
CAST(NULL AS FLOAT),
+ 'double_field', CAST(NULL AS DOUBLE),
+ 'binary_field', CAST(NULL AS BINARY),
+ 'decimal_field', CAST(NULL AS DECIMAL(18, 6)),
+ 'date_field', CAST(NULL AS DATE),
+ 'timestamp_field', CAST(NULL AS TIMESTAMP)),
+ NULL
+ );
+ """
+
+ // Example: write one more Iceberg row through Spark SQL.
+ spark_iceberg """
+ INSERT INTO demo.${dbName}.spark_written_iceberg_demo VALUES
+ (
+ 4, 'doris', 40, 'doris-string', true,
+ 4000, 4000000000,
+ CAST(4.5 AS FLOAT), CAST(40.75 AS DOUBLE),
+ CAST(44.44 AS DECIMAL(9, 2)),
+ CAST(4444.000001 AS DECIMAL(18, 6)),
+ CAST(444444444.000000000001 AS DECIMAL(38, 12)),
+ DATE '2024-03-22', TIMESTAMP '2024-03-22 14:02:03.000001',
+ CAST('doris-bin' AS BINARY),
+ ARRAY(7, 8, 9), ARRAY('d', 'o', 'ris'),
+ ARRAY(true, true),
+ ARRAY(CAST('doris-array-bin' AS BINARY)),
+ ARRAY(CAST(4.000001 AS DECIMAL(18, 6))),
+ ARRAY(DATE '2024-03-22', DATE '2024-03-23'),
+ ARRAY(TIMESTAMP '2024-03-22 14:02:03.000001'),
+ MAP('math', 100, 'eng', 99),
+ MAP(6, 'six', 7, 'seven'),
+ MAP(true, false),
+ MAP('payload', CAST('doris-map-bin' AS BINARY)),
+ MAP(CAST(6.25 AS DECIMAL(8, 2)), CAST(7.50 AS DECIMAL(8, 2))),
+ NAMED_STRUCT('city', 'Chengdu', 'zip', 610000),
+ NAMED_STRUCT('string_field', 'doris', 'bool_field', true,
'int_field', 4000,
+ 'bigint_field', 4000000000, 'float_field', CAST(4.5 AS
FLOAT),
+ 'double_field', CAST(40.75 AS DOUBLE),
+ 'binary_field', CAST('doris-struct-bin' AS BINARY),
+ 'decimal_field', CAST(4444.000001 AS DECIMAL(18, 6)),
+ 'date_field', DATE '2024-03-22',
+ 'timestamp_field', TIMESTAMP '2024-03-22 14:02:03.000001'),
+ MAP('term', ARRAY(NAMED_STRUCT('score', 100, 'label',
'excellent')))
+ ),
+ (
+ 5, 'edge', 0, '', false,
+ 0, CAST('-9223372036854775808' AS BIGINT),
+ CAST(0.1 AS FLOAT), CAST(-0.1 AS DOUBLE),
+ CAST(0.00 AS DECIMAL(9, 2)),
+ CAST(-0.000001 AS DECIMAL(18, 6)),
+ CAST(99999999999999999999999999.999999999999 AS DECIMAL(38,
12)),
+ DATE '1970-01-01', TIMESTAMP '1970-01-01 00:00:00.000000',
+ CAST('' AS BINARY),
+ CAST(ARRAY() AS ARRAY<INT>),
+ ARRAY('', 'space value', 'edge-value'),
+ CAST(ARRAY() AS ARRAY<BOOLEAN>),
+ CAST(ARRAY() AS ARRAY<BINARY>),
+ CAST(ARRAY() AS ARRAY<DECIMAL(18, 6)>),
+ CAST(ARRAY() AS ARRAY<DATE>),
+ CAST(ARRAY() AS ARRAY<TIMESTAMP>),
+ map_from_arrays(CAST(ARRAY() AS ARRAY<STRING>), CAST(ARRAY()
AS ARRAY<INT>)),
+ map_from_arrays(CAST(ARRAY() AS ARRAY<INT>), CAST(ARRAY() AS
ARRAY<STRING>)),
+ map_from_arrays(CAST(ARRAY() AS ARRAY<BOOLEAN>), CAST(ARRAY()
AS ARRAY<BOOLEAN>)),
+ map_from_arrays(CAST(ARRAY() AS ARRAY<STRING>), CAST(ARRAY()
AS ARRAY<BINARY>)),
+ map_from_arrays(CAST(ARRAY() AS ARRAY<DECIMAL(8, 2)>),
+ CAST(ARRAY() AS ARRAY<DECIMAL(8, 2)>)),
+ NAMED_STRUCT('city', '', 'zip', 0),
+ NAMED_STRUCT('string_field', '', 'bool_field', false,
'int_field', 0,
+ 'bigint_field', CAST('-9223372036854775808' AS BIGINT),
+ 'float_field', CAST(-0.0 AS FLOAT), 'double_field',
CAST(0.0 AS DOUBLE),
+ 'binary_field', CAST('' AS BINARY),
+ 'decimal_field', CAST(-0.000001 AS DECIMAL(18, 6)),
+ 'date_field', DATE '1970-01-01',
+ 'timestamp_field', TIMESTAMP '1970-01-01 00:00:00.000000'),
+ MAP('empty', CAST(ARRAY() AS ARRAY<STRUCT<score:INT,
label:STRING>>),
+ 'blank', ARRAY(NAMED_STRUCT('score', 0, 'label', '')))
+ );
+ """
+
+ sql """drop catalog if exists ${catalogName}"""
+ sql """
+ CREATE CATALOG ${catalogName} PROPERTIES (
+ 'type'='iceberg',
+ 'iceberg.catalog.type'='rest',
+ 'uri' = 'http://${externalEnvIp}:${restPort}',
+ 's3.access_key' = 'admin',
+ 's3.secret_key' = 'password',
+ 's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
+ 's3.region' = 'us-east-1',
+ 'enable.mapping.varbinary' = 'true'
+ );
+ """
+
+ sql """switch ${catalogName}"""
+
+ def sparkBasicRows = spark_iceberg """
+ SELECT id, name, score
+ FROM demo.${dbName}.spark_written_iceberg_demo
+ ORDER BY id
+ """
+ // Example 1: compare Spark Iceberg query result with explicit expected
values.
+ assertEquals(expectedBasicRows, sparkBasicRows)
+
+ def dorisBasicRows = sql """
+ SELECT id, name, score
+ FROM ${dbName}.spark_written_iceberg_demo
+ ORDER BY id
+ """
+ // Example 1: compare Doris Iceberg query result with explicit expected
values.
+ assertEquals(expectedBasicRows, dorisBasicRows)
+
+ // Example 2: compare Doris and Spark query results.
+ def sparkRows = spark_iceberg """
+ SELECT *
+ FROM demo.${dbName}.spark_written_iceberg_demo
+ ORDER BY id
+ """
+ def dorisRows = sql """
+ SELECT *
+ FROM ${dbName}.spark_written_iceberg_demo
+ ORDER BY id
+ """
+ assertSparkDorisResultEquals(sparkRows, dorisRows)
+
+ def sparkAggRows = spark_iceberg """
+ SELECT count(*), sum(score)
+ FROM demo.${dbName}.spark_written_iceberg_demo
+ """
+ // Compare Spark Iceberg aggregate result with explicit expected values.
+ assertEquals(expectedAggRows, sparkAggRows)
+
+ def dorisAggRows = sql """
+ SELECT count(*), sum(score)
+ FROM ${dbName}.spark_written_iceberg_demo
+ """
+ assertSparkDorisResultEquals(sparkAggRows, dorisAggRows)
+}
diff --git
a/regression-test/suites/external_table_p0/paimon/test_paimon_spark_doris_consistency_demo.groovy
b/regression-test/suites/external_table_p0/paimon/test_paimon_spark_doris_consistency_demo.groovy
new file mode 100644
index 00000000000..ab2cfe4090c
--- /dev/null
+++
b/regression-test/suites/external_table_p0/paimon/test_paimon_spark_doris_consistency_demo.groovy
@@ -0,0 +1,317 @@
+// 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.
+
+suite("test_paimon_spark_doris_consistency_demo", "p0,external,paimon") {
+ String enabled = context.config.otherConfigs.get("enablePaimonTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("disable paimon test.")
+ return
+ }
+
+ String catalogName = "test_paimon_spark_doris_consistency_demo"
+ String dbName = "paimon_spark_doris_consistency_demo_db"
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+
+ def expectedBasicRows = [
+ [1, "alice", 10],
+ [2, "bob", 20],
+ [3, "cindy", null],
+ [4, "doris", 40],
+ [5, "edge", 0]
+ ]
+ def expectedAggRows = [[5L, 70L]]
+
+ // Example: execute multiple Spark Paimon statements in one JDBC
connection.
+ spark_paimon_multi """
+ SET spark.sql.binaryOutputStyle=HEX;
+ SET spark.sql.preserveCharVarcharTypeInfo=true;
+ SET spark.sql.timestampType=TIMESTAMP_NTZ;
+ CREATE DATABASE IF NOT EXISTS paimon.${dbName};
+ DROP TABLE IF EXISTS paimon.${dbName}.spark_written_paimon_demo;
+ CREATE TABLE paimon.${dbName}.spark_written_paimon_demo (
+ id INT,
+ name STRING,
+ score INT,
+ string_col STRING,
+ varchar_col VARCHAR(20),
+ char_col CHAR(10),
+ bool_col BOOLEAN,
+ tinyint_col TINYINT,
+ smallint_col SMALLINT,
+ int_col INT,
+ bigint_col BIGINT,
+ float_col FLOAT,
+ double_col DOUBLE,
+ decimal_small_col DECIMAL(9, 2),
+ decimal_col DECIMAL(18, 6),
+ decimal_wide_col DECIMAL(38, 12),
+ date_col DATE,
+ timestamp_col TIMESTAMP,
+ binary_col BINARY,
+ array_col ARRAY<INT>,
+ array_tinyint_col ARRAY<TINYINT>,
+ array_smallint_col ARRAY<SMALLINT>,
+ array_string_col ARRAY<STRING>,
+ array_bool_col ARRAY<BOOLEAN>,
+ array_binary_col ARRAY<BINARY>,
+ array_decimal_col ARRAY<DECIMAL(18, 6)>,
+ array_date_col ARRAY<DATE>,
+ array_timestamp_col ARRAY<TIMESTAMP>,
+ map_col MAP<STRING, INT>,
+ map_int_string_col MAP<INT, STRING>,
+ map_bool_col MAP<BOOLEAN, BOOLEAN>,
+ map_binary_col MAP<STRING, BINARY>,
+ map_decimal_col MAP<DECIMAL(8, 2), DECIMAL(8, 2)>,
+ struct_col STRUCT<city:STRING, zip:INT>,
+ struct_all_col STRUCT<string_field:STRING, bool_field:BOOLEAN,
int_field:INT,
+ bigint_field:BIGINT, float_field:FLOAT,
double_field:DOUBLE,
+ binary_field:BINARY,
decimal_field:DECIMAL(18, 6), date_field:DATE,
+ timestamp_field:TIMESTAMP>,
+ nested_col MAP<STRING, ARRAY<STRUCT<score:INT, label:STRING>>>
+ ) USING paimon;
+ INSERT INTO paimon.${dbName}.spark_written_paimon_demo VALUES
+ (
+ 1, 'alice', 10, 'alice-string', 'alice-varchar', 'alice_char',
true,
+ CAST(7 AS TINYINT), CAST(70 AS SMALLINT), 700, 7000000000,
+ CAST(1.25 AS FLOAT), CAST(10.125 AS DOUBLE),
+ CAST(12.34 AS DECIMAL(9, 2)),
+ CAST(12345.678900 AS DECIMAL(18, 6)),
+ CAST(123456789.012345678901 AS DECIMAL(38, 12)),
+ DATE '2024-03-20', TIMESTAMP '2024-03-20 12:00:00.123456',
+ CAST('alice-bin' AS BINARY),
+ ARRAY(1, 2, 3), ARRAY(CAST(1 AS TINYINT), CAST(2 AS TINYINT)),
+ ARRAY(CAST(10 AS SMALLINT), CAST(20 AS SMALLINT)),
+ ARRAY(CAST(NULL AS STRING), 'a', 'b'),
+ ARRAY(true, false),
+ ARRAY(CAST('alice-array-bin' AS BINARY), CAST(NULL AS BINARY)),
+ ARRAY(CAST(1.250000 AS DECIMAL(18, 6)), CAST(2.500000 AS
DECIMAL(18, 6))),
+ ARRAY(DATE '2024-03-20', DATE '2024-03-21'),
+ ARRAY(TIMESTAMP '2024-03-20 12:00:00.123456'),
+ MAP('math', 90, 'eng', 95),
+ MAP(1, 'one', 2, 'two'),
+ MAP(true, false, false, true),
+ MAP('payload', CAST('alice-map-bin' AS BINARY)),
+ MAP(CAST(1.25 AS DECIMAL(8, 2)), CAST(2.50 AS DECIMAL(8, 2)),
+ CAST(3.75 AS DECIMAL(8, 2)), CAST(4.00 AS DECIMAL(8, 2))),
+ NAMED_STRUCT('city', 'Beijing', 'zip', 100000),
+ NAMED_STRUCT('string_field', 'alice', 'bool_field', true,
'int_field', 700,
+ 'bigint_field', 7000000000, 'float_field', CAST(1.25 AS
FLOAT),
+ 'double_field', CAST(10.125 AS DOUBLE),
+ 'binary_field', CAST('alice-struct-bin' AS BINARY),
+ 'decimal_field', CAST(12345.678900 AS DECIMAL(18, 6)),
+ 'date_field', DATE '2024-03-20',
+ 'timestamp_field', TIMESTAMP '2024-03-20 12:00:00.123456'),
+ MAP('term', ARRAY(NAMED_STRUCT('score', 90, 'label', 'good')))
+ ),
+ (
+ 2, 'bob', 20, 'bob-string', 'bob-varchar', 'bob_char__', false,
+ CAST(-8 AS TINYINT), CAST(-80 AS SMALLINT), -800, -8000000000,
+ CAST(-2.5 AS FLOAT), CAST(-20.25 AS DOUBLE),
+ CAST(-98.76 AS DECIMAL(9, 2)),
+ CAST(-9876.543210 AS DECIMAL(18, 6)),
+ CAST(-987654321.012345678901 AS DECIMAL(38, 12)),
+ DATE '2024-03-21', TIMESTAMP '2024-03-21 13:01:02.654321',
+ CAST('bob-bin' AS BINARY),
+ ARRAY(4, 5), ARRAY(CAST(-1 AS TINYINT), CAST(-2 AS TINYINT)),
+ ARRAY(CAST(-10 AS SMALLINT), CAST(-20 AS SMALLINT)),
+ ARRAY('x', CAST(NULL AS STRING), 'z'),
+ ARRAY(false, true),
+ ARRAY(CAST('bob-array-bin' AS BINARY), CAST('bob-array-bin-2'
AS BINARY)),
+ ARRAY(CAST(-3.750000 AS DECIMAL(18, 6)), CAST(4.125000 AS
DECIMAL(18, 6))),
+ ARRAY(DATE '2024-03-21'),
+ ARRAY(TIMESTAMP '2024-03-21 13:01:02.654321',
+ TIMESTAMP '2024-03-21 13:01:03.000000'),
+ MAP('math', 80, 'eng', 85),
+ MAP(3, 'three', 4, 'four'),
+ MAP(true, true, false, false),
+ MAP('payload', CAST('bob-map-bin' AS BINARY)),
+ MAP(CAST(-1.25 AS DECIMAL(8, 2)), CAST(-2.50 AS DECIMAL(8,
2))),
+ NAMED_STRUCT('city', 'Shanghai', 'zip', 200000),
+ NAMED_STRUCT('string_field', 'bob', 'bool_field', false,
'int_field', -800,
+ 'bigint_field', -8000000000, 'float_field', CAST(-2.5 AS
FLOAT),
+ 'double_field', CAST(-20.25 AS DOUBLE),
+ 'binary_field', CAST('bob-struct-bin' AS BINARY),
+ 'decimal_field', CAST(-9876.543210 AS DECIMAL(18, 6)),
+ 'date_field', DATE '2024-03-21',
+ 'timestamp_field', TIMESTAMP '2024-03-21 13:01:02.654321'),
+ MAP('term', ARRAY(
+ NAMED_STRUCT('score', 80, 'label', 'pass'),
+ NAMED_STRUCT('score', 85, 'label', 'better')
+ ))
+ ),
+ (
+ 3, 'cindy', NULL, NULL, NULL, NULL, NULL,
+ NULL, NULL, NULL,
+ NULL, NULL, NULL, NULL, NULL,
+ NULL, NULL, NULL, NULL,
+ ARRAY(CAST(NULL AS INT), 6), ARRAY(CAST(NULL AS TINYINT)),
+ ARRAY(CAST(NULL AS SMALLINT)),
+ ARRAY(CAST(NULL AS STRING)),
+ ARRAY(CAST(NULL AS BOOLEAN), true),
+ ARRAY(CAST(NULL AS BINARY)),
+ ARRAY(CAST(NULL AS DECIMAL(18, 6))),
+ ARRAY(CAST(NULL AS DATE)),
+ ARRAY(CAST(NULL AS TIMESTAMP)),
+ MAP('science', CAST(NULL AS INT)),
+ MAP(5, CAST(NULL AS STRING)),
+ MAP(false, CAST(NULL AS BOOLEAN)),
+ MAP('payload', CAST(NULL AS BINARY)),
+ MAP(CAST(5.25 AS DECIMAL(8, 2)), CAST(NULL AS DECIMAL(8, 2))),
+ NAMED_STRUCT('city', CAST(NULL AS STRING), 'zip', CAST(NULL AS
INT)),
+ NAMED_STRUCT('string_field', CAST(NULL AS STRING),
'bool_field', CAST(NULL AS BOOLEAN),
+ 'int_field', CAST(NULL AS INT),
+ 'bigint_field', CAST(NULL AS BIGINT), 'float_field',
CAST(NULL AS FLOAT),
+ 'double_field', CAST(NULL AS DOUBLE),
+ 'binary_field', CAST(NULL AS BINARY),
+ 'decimal_field', CAST(NULL AS DECIMAL(18, 6)),
+ 'date_field', CAST(NULL AS DATE),
+ 'timestamp_field', CAST(NULL AS TIMESTAMP)),
+ NULL
+ );
+ """
+
+ // Example: write one more Paimon row through Spark SQL.
+ spark_paimon """
+ INSERT INTO paimon.${dbName}.spark_written_paimon_demo VALUES
+ (
+ 4, 'doris', 40, 'doris-string', 'doris-varchar', 'doris_char',
true,
+ CAST(4 AS TINYINT), CAST(400 AS SMALLINT), 4000, 4000000000,
+ CAST(4.5 AS FLOAT), CAST(40.75 AS DOUBLE),
+ CAST(44.44 AS DECIMAL(9, 2)),
+ CAST(4444.000001 AS DECIMAL(18, 6)),
+ CAST(444444444.000000000001 AS DECIMAL(38, 12)),
+ DATE '2024-03-22', TIMESTAMP '2024-03-22 14:02:03.000001',
+ CAST('doris-bin' AS BINARY),
+ ARRAY(7, 8, 9), ARRAY(CAST(3 AS TINYINT), CAST(4 AS TINYINT)),
+ ARRAY(CAST(30 AS SMALLINT), CAST(40 AS SMALLINT)),
+ ARRAY('d', 'o', 'ris'),
+ ARRAY(true, true),
+ ARRAY(CAST('doris-array-bin' AS BINARY)),
+ ARRAY(CAST(4.000001 AS DECIMAL(18, 6))),
+ ARRAY(DATE '2024-03-22', DATE '2024-03-23'),
+ ARRAY(TIMESTAMP '2024-03-22 14:02:03.000001'),
+ MAP('math', 100, 'eng', 99),
+ MAP(6, 'six', 7, 'seven'),
+ MAP(true, false),
+ MAP('payload', CAST('doris-map-bin' AS BINARY)),
+ MAP(CAST(6.25 AS DECIMAL(8, 2)), CAST(7.50 AS DECIMAL(8, 2))),
+ NAMED_STRUCT('city', 'Chengdu', 'zip', 610000),
+ NAMED_STRUCT('string_field', 'doris', 'bool_field', true,
'int_field', 4000,
+ 'bigint_field', 4000000000, 'float_field', CAST(4.5 AS
FLOAT),
+ 'double_field', CAST(40.75 AS DOUBLE),
+ 'binary_field', CAST('doris-struct-bin' AS BINARY),
+ 'decimal_field', CAST(4444.000001 AS DECIMAL(18, 6)),
+ 'date_field', DATE '2024-03-22',
+ 'timestamp_field', TIMESTAMP '2024-03-22 14:02:03.000001'),
+ MAP('term', ARRAY(NAMED_STRUCT('score', 100, 'label',
'excellent')))
+ ),
+ (
+ 5, 'edge', 0, '', '', 'edge_char_', false,
+ CAST(-128 AS TINYINT), CAST(-32768 AS SMALLINT), 0,
+ CAST('-9223372036854775808' AS BIGINT),
+ CAST(0.1 AS FLOAT), CAST(-0.1 AS DOUBLE),
+ CAST(0.00 AS DECIMAL(9, 2)),
+ CAST(-0.000001 AS DECIMAL(18, 6)),
+ CAST(99999999999999999999999999.999999999999 AS DECIMAL(38,
12)),
+ DATE '1970-01-01', TIMESTAMP '1970-01-01 00:00:00.000000',
+ CAST('' AS BINARY),
+ CAST(ARRAY() AS ARRAY<INT>),
+ CAST(ARRAY() AS ARRAY<TINYINT>),
+ CAST(ARRAY() AS ARRAY<SMALLINT>),
+ ARRAY('', 'space value', 'edge-value'),
+ CAST(ARRAY() AS ARRAY<BOOLEAN>),
+ CAST(ARRAY() AS ARRAY<BINARY>),
+ CAST(ARRAY() AS ARRAY<DECIMAL(18, 6)>),
+ CAST(ARRAY() AS ARRAY<DATE>),
+ CAST(ARRAY() AS ARRAY<TIMESTAMP>),
+ map_from_arrays(CAST(ARRAY() AS ARRAY<STRING>), CAST(ARRAY()
AS ARRAY<INT>)),
+ map_from_arrays(CAST(ARRAY() AS ARRAY<INT>), CAST(ARRAY() AS
ARRAY<STRING>)),
+ map_from_arrays(CAST(ARRAY() AS ARRAY<BOOLEAN>), CAST(ARRAY()
AS ARRAY<BOOLEAN>)),
+ map_from_arrays(CAST(ARRAY() AS ARRAY<STRING>), CAST(ARRAY()
AS ARRAY<BINARY>)),
+ map_from_arrays(CAST(ARRAY() AS ARRAY<DECIMAL(8, 2)>),
+ CAST(ARRAY() AS ARRAY<DECIMAL(8, 2)>)),
+ NAMED_STRUCT('city', '', 'zip', 0),
+ NAMED_STRUCT('string_field', '', 'bool_field', false,
'int_field', 0,
+ 'bigint_field', CAST('-9223372036854775808' AS BIGINT),
+ 'float_field', CAST(-0.0 AS FLOAT), 'double_field',
CAST(0.0 AS DOUBLE),
+ 'binary_field', CAST('' AS BINARY),
+ 'decimal_field', CAST(-0.000001 AS DECIMAL(18, 6)),
+ 'date_field', DATE '1970-01-01',
+ 'timestamp_field', TIMESTAMP '1970-01-01 00:00:00.000000'),
+ MAP('empty', CAST(ARRAY() AS ARRAY<STRUCT<score:INT,
label:STRING>>),
+ 'blank', ARRAY(NAMED_STRUCT('score', 0, 'label', '')))
+ );
+ """
+
+ sql """drop catalog if exists ${catalogName}"""
+ sql """
+ CREATE CATALOG ${catalogName} PROPERTIES (
+ 'type' = 'paimon',
+ 'warehouse' = 's3://warehouse/wh',
+ 's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
+ 's3.access_key' = 'admin',
+ 's3.secret_key' = 'password',
+ 's3.path.style.access' = 'true',
+ 'enable.mapping.varbinary' = 'true'
+ );
+ """
+
+ sql """switch ${catalogName}"""
+
+ def sparkBasicRows = spark_paimon """
+ SELECT id, name, score
+ FROM paimon.${dbName}.spark_written_paimon_demo
+ ORDER BY id
+ """
+ // Example 1: compare Spark Paimon query result with explicit expected
values.
+ assertEquals(expectedBasicRows, sparkBasicRows)
+
+ def dorisBasicRows = sql """
+ SELECT id, name, score
+ FROM ${dbName}.spark_written_paimon_demo
+ ORDER BY id
+ """
+ // Example 1: compare Doris Paimon query result with explicit expected
values.
+ assertEquals(expectedBasicRows, dorisBasicRows)
+
+ // Example 2: compare Doris and Spark query results.
+ def sparkRows = spark_paimon """
+ SELECT *
+ FROM paimon.${dbName}.spark_written_paimon_demo
+ ORDER BY id
+ """
+ def dorisRows = sql """
+ SELECT *
+ FROM ${dbName}.spark_written_paimon_demo
+ ORDER BY id
+ """
+ assertSparkDorisResultEquals(sparkRows, dorisRows)
+
+ def sparkAggRows = spark_paimon """
+ SELECT count(*), sum(score)
+ FROM paimon.${dbName}.spark_written_paimon_demo
+ """
+ // Compare Spark Paimon aggregate result with explicit expected values.
+ assertEquals(expectedAggRows, sparkAggRows)
+
+ def dorisAggRows = sql """
+ SELECT count(*), sum(score)
+ FROM ${dbName}.spark_written_paimon_demo
+ """
+ assertSparkDorisResultEquals(sparkAggRows, dorisAggRows)
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]