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

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/dev/pr-12862-e5e347591b5e731ae0a5c3edd61b2f2f8fa198b2
in repository https://gitbox.apache.org/repos/asf/seatunnel.git

commit 2cd3400d3fb73930eea7320bd68cac38c2f15fc4
Author: zhengxiang378928908-code <[email protected]>
AuthorDate: Wed Oct 21 11:51:35 2026 +0000

    [Fix][Translation][Spark] Carry DECIMAL with precision above 38 through 
Spark (#12862)
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../api-design/spark-translation-layer.md          |  18 +
 docs/en/engines/spark.md                           |  15 +
 .../introduction/concepts/incompatible-changes.md  |  11 +
 .../api-design/spark-translation-layer.md          |  18 +
 docs/zh/engines/spark.md                           |  15 +
 .../introduction/concepts/incompatible-changes.md  |  11 +
 .../connector/assertion/FakeSourceToAssertIT.java  |  26 +
 .../assertion/fake_wide_decimal_map_to_assert.conf |  55 +++
 .../assertion/fake_wide_decimal_to_assert.conf     | 146 ++++++
 .../spark/exception/SparkTranslationErrorCode.java |  50 ++
 .../spark/execution/MultiTableManager.java         |   7 +
 .../spark/serialization/InternalRowConverter.java  |  63 ++-
 .../spark/serialization/SeaTunnelRowConverter.java |  74 ++-
 .../spark/utils/TypeConverterUtils.java            |  40 +-
 .../translation/spark/utils/WideDecimalUtils.java  | 285 +++++++++++
 .../translation/spark/utils/WideDecimalTest.java   | 536 +++++++++++++++++++++
 16 files changed, 1348 insertions(+), 22 deletions(-)

diff --git a/docs/en/architecture/api-design/spark-translation-layer.md 
b/docs/en/architecture/api-design/spark-translation-layer.md
index 6b75b29202..72503e0bba 100644
--- a/docs/en/architecture/api-design/spark-translation-layer.md
+++ b/docs/en/architecture/api-design/spark-translation-layer.md
@@ -102,6 +102,24 @@ This is one of the most sensitive boundaries in the Spark 
path, especially for:
 - nested types
 - nullability
 
+### DECIMAL precision above 38
+
+Spark's `DecimalType` stops at precision 38. `TypeConverterUtils` maps a 
SeaTunnel `DecimalType(p, s)` with `p > 38` as follows, in the same way `TIME` 
and `TIMESTAMP_TZ` are carried with a metadata flag:
+
+| Position of the wide decimal | Spark schema |
+|---|---|
+| Top-level field, or a field of a `ROW` when every container on the path is a 
`ROW` | `StringType`, with `StructField` metadata `seatunnel_decimal_precision 
= p` and `seatunnel_decimal_scale = s` (both long) |
+| Anywhere inside an `ARRAY` or `MAP` | rejected with `SPARK-TRANSLATION-01` |
+
+Rules:
+
+- `MultiTableManager` checks every source `CatalogTable` before `mergeSchema`, 
because merging renames the columns to `column0..N`. The error names the table, 
the column path (`.element`, `.key`, `.value`), the declared type, the limit of 
38 and the workarounds.
+- Write (`InternalRowConverter` and `SeaTunnelRowConverter`): `null` stays 
`null`. The value is padded with `setScale(s, RoundingMode.UNNECESSARY)` and 
written with `toPlainString()`. A value that would need rounding, or has more 
than `p` digits, is rejected with `SPARK-TRANSLATION-02`.
+- Read: the text must match `-?\d+(\.\d+)?`, have exactly `s` fraction digits 
and at most `p` digits. Anything else is rejected with `SPARK-TRANSLATION-02`, 
never rescaled.
+- Schema read-back (`TypeConverterUtils.convert(StructType)`): a `StringType` 
field with both metadata keys becomes `DecimalType(p, s)` again. Only one key, 
the keys on another type, or `p <= 38` is rejected with `SPARK-TRANSLATION-03`.
+
+Row values are converted from the SeaTunnel row type, so transforms and sinks 
always see `DecimalType(p, s)`.
+
 ## Version Split
 
 Spark translation in SeaTunnel is version-aware. Spark 2.4 and Spark 3.x do 
not expose identical datasource APIs, so SeaTunnel keeps separate modules and 
adapters for major Spark lines.
diff --git a/docs/en/engines/spark.md b/docs/en/engines/spark.md
index 1a045b8650..acfcfff910 100644
--- a/docs/en/engines/spark.md
+++ b/docs/en/engines/spark.md
@@ -98,6 +98,21 @@ sink {
 
 If you need more transform options, see [Transforms Catalog](../transforms) 
and [Transform Common Options](../transforms/common-options/common-options.md).
 
+## DECIMAL Precision Above 38
+
+Spark's `DECIMAL` type supports a precision of at most 38. SeaTunnel types can 
be wider, for example a Doris `LARGEINT` (`DECIMAL(39, 0)`) or a PostgreSQL 
`NUMERIC(50, 10)`.
+
+- **Top-level columns and fields inside a `ROW`**: SeaTunnel carries the value 
through Spark as plain decimal text in a `STRING` column, and records the 
declared precision and scale in the column metadata. Transforms and sinks still 
receive `DECIMAL(p, s)` with the exact value, so auto-created sink tables get 
the same column type as on the Zeta and Flink engines. Only Spark's own view of 
the data, such as the Dataset schema or a temporary view, shows the column as 
`STRING`.
+- **Inside an `ARRAY` or `MAP`** (element, key or value, at any depth): there 
is no column to hold the metadata, so the job fails at start, before any data 
is read. The error names the table, the column path (for example `tags.element` 
or `attrs.value`), the declared type and the Spark limit.
+
+SeaTunnel never rounds or truncates these values. A value that does not fit 
the declared precision or scale fails the job with an error naming the column.
+
+Workarounds for an `ARRAY` or `MAP` column. They all act at the source, 
because on Spark the source schema is built before any transform runs:
+
+- leave the column out at the source, for example with Doris 
`doris.read.field` or a JDBC `query` that omits it;
+- cast it to a string, or to a narrower type if you know the values fit, in 
the source query;
+- run the job on the Zeta or Flink engine, which keep `DECIMAL(p > 38)`.
+
 ## Running From A Source Checkout
 
 If you are running examples from the repository source tree, the example 
module is:
diff --git a/docs/en/introduction/concepts/incompatible-changes.md 
b/docs/en/introduction/concepts/incompatible-changes.md
index eff49771be..b24119253f 100644
--- a/docs/en/introduction/concepts/incompatible-changes.md
+++ b/docs/en/introduction/concepts/incompatible-changes.md
@@ -5,6 +5,17 @@ You need to check this document before you upgrade to related 
version.
 
 ## dev
 
+### Spark: DECIMAL with precision above 38
+
+- **Behavior change: on the Spark engine, `DECIMAL(p, s)` with `p > 38` no 
longer fails at schema creation when it is a top-level column or a field inside 
a `ROW`**
+  - **Description**: Spark's decimal type stops at precision 38, so any such 
column failed with `decimal can only support precision up to 38` before any row 
was read. SeaTunnel now carries these values through Spark as plain decimal 
text, with the declared precision and scale in the column metadata, and 
restores `DECIMAL(p, s)` for transforms and sinks.
+  - **Impact**:
+    - Jobs that read such columns on Spark 2.4 or 3.3 now run and keep every 
digit.
+    - A `DECIMAL(p > 38)` inside an `ARRAY` or `MAP` now fails at job start 
with error `SPARK-TRANSLATION-01`, which names the table, the column path and 
the workarounds. If the Doris source declares `LARGEINT` as `DECIMAL(39, 0)`, 
this applies to Doris `ARRAY<LARGEINT>` columns and `LARGEINT` map keys and 
values on Spark, which could overflow above 20 digits with the old `DECIMAL(20, 
0)` type.
+    - A value that does not fit the declared precision or scale fails the job 
with `SPARK-TRANSLATION-02` instead of being rounded.
+    - In Spark's own view of the data, such as the Dataset schema or a 
temporary view, these columns are `STRING`.
+  - **Migration Guide**: No change is needed for top-level or `ROW` columns. 
For an `ARRAY` or `MAP` column, leave it out at the source, cast it in the 
source query, or run the job on the Zeta or Flink engine. A `Sql` transform 
cannot help here, because on Spark the source schema is built before any 
transform runs. See [SeaTunnel With 
Spark](../../engines/spark.md#decimal-precision-above-38).
+
 ### File sink: XML applies `date_format`, `datetime_format` and `time_format`
 
 - **Behavior change: the XML writer of the file sinks formats DATE, TIME and 
TIMESTAMP values with the configured formats**
diff --git a/docs/zh/architecture/api-design/spark-translation-layer.md 
b/docs/zh/architecture/api-design/spark-translation-layer.md
index 3eafd70af1..ff2ec292f7 100644
--- a/docs/zh/architecture/api-design/spark-translation-layer.md
+++ b/docs/zh/architecture/api-design/spark-translation-layer.md
@@ -102,6 +102,24 @@ Spark 转换层对 schema 转换非常敏感,因为 Spark 本身依赖严格
 - 嵌套类型
 - nullability
 
+### 精度超过 38 的 DECIMAL
+
+Spark 的 `DecimalType` 精度最大为 38。`TypeConverterUtils` 对 `p > 38` 的 SeaTunnel 
`DecimalType(p, s)` 按下表映射,做法与 `TIME`、`TIMESTAMP_TZ` 借助 metadata 标记传递相同:
+
+| 宽 DECIMAL 的位置 | Spark schema |
+|---|---|
+| 顶层字段,或路径上所有容器都是 `ROW` 时 `ROW` 内的字段 | `StringType`,`StructField` metadata 为 
`seatunnel_decimal_precision = p` 和 `seatunnel_decimal_scale = s`(均为 long) |
+| `ARRAY` 或 `MAP` 内任意位置 | 以 `SPARK-TRANSLATION-01` 拒绝 |
+
+规则:
+
+- `MultiTableManager` 在 `mergeSchema` 之前检查每个 Source 的 
`CatalogTable`,因为合并后列名会变成 
`column0..N`。报错信息包含表名、列路径(`.element`、`.key`、`.value`)、声明的类型、上限 38 以及绕过办法。
+- 写入(`InternalRowConverter` 和 `SeaTunnelRowConverter`):`null` 保持为 `null`。值先用 
`setScale(s, RoundingMode.UNNECESSARY)` 补齐小数位,再用 `toPlainString()` 
输出。需要四舍五入或位数超过 `p` 的值以 `SPARK-TRANSLATION-02` 拒绝。
+- 读取:文本必须匹配 `-?\d+(\.\d+)?`,小数位数必须正好是 `s`,总位数不超过 `p`。其他情况一律以 
`SPARK-TRANSLATION-02` 拒绝,不会重新调整小数位数。
+- Schema 回读(`TypeConverterUtils.convert(StructType)`):带有两个 metadata 键的 
`StringType` 字段会还原为 `DecimalType(p, s)`。只有一个键、键出现在其他类型上或 `p <= 38` 时,以 
`SPARK-TRANSLATION-03` 拒绝。
+
+行数据按 SeaTunnel 的行类型转换,因此 Transform 和 Sink 看到的始终是 `DecimalType(p, s)`。
+
 ## 版本拆分
 
 SeaTunnel 的 Spark 转换层是按版本线拆分的。Spark 2.4 和 Spark 3.x 的 datasource API 
不完全一致,因此仓库里保留了不同 major 版本的独立适配模块。
diff --git a/docs/zh/engines/spark.md b/docs/zh/engines/spark.md
index 53f1ef160d..f2a3878717 100644
--- a/docs/zh/engines/spark.md
+++ b/docs/zh/engines/spark.md
@@ -98,6 +98,21 @@ sink {
 
 如果你需要更多 transform 能力,继续查看 [Transforms 目录](../transforms) 和 [Transform 
通用参数](../transforms/common-options/common-options.md)。
 
+## 精度超过 38 的 DECIMAL
+
+Spark 的 `DECIMAL` 类型精度最大为 38,而 SeaTunnel 的类型可以更宽,例如 Doris 
`LARGEINT`(`DECIMAL(39, 0)`)或 PostgreSQL `NUMERIC(50, 10)`。
+
+- **顶层列和 `ROW` 内的字段**:SeaTunnel 在 Spark 中把值作为普通十进制文本放在 `STRING` 列里传递,并在列的 
metadata 中记录声明的精度和小数位数。Transform 和 Sink 收到的仍然是 `DECIMAL(p, s)` 和原值,所以 Sink 
自动建表得到的列类型与 Zeta、Flink 引擎一致。只有 Spark 自己看到的数据(例如 Dataset 的 schema 或临时视图)中,该列显示为 
`STRING`。
+- **在 `ARRAY` 或 `MAP` 内**(元素、键或值,任意嵌套层级):没有可以存放 metadata 
的列,因此作业在启动时、读取任何数据之前就会失败。报错信息包含表名、列路径(例如 `tags.element` 或 
`attrs.value`)、声明的类型以及 Spark 的上限。
+
+SeaTunnel 不会对这些值做四舍五入或截断。值超出声明的精度或小数位数时,作业会失败,并在报错中给出列名。
+
+`ARRAY` 或 `MAP` 列的绕过办法。这些办法都在 Source 端生效,因为在 Spark 上 Source 的 schema 在任何 
Transform 运行之前就已构建:
+
+- 在 Source 端不读取该列,例如使用 Doris 的 `doris.read.field`,或在 JDBC 的 `query` 中不选该列;
+- 在 Source 查询中把它转换为字符串;如果确定数值放得下,也可以转换为更窄的类型;
+- 改用 Zeta 或 Flink 引擎运行作业,这两个引擎支持 `DECIMAL(p > 38)`。
+
 ## 从源码仓库运行示例
 
 如果你是在源码仓库里运行示例,对应模块是:
diff --git a/docs/zh/introduction/concepts/incompatible-changes.md 
b/docs/zh/introduction/concepts/incompatible-changes.md
index bb0974bc39..f2c287a693 100644
--- a/docs/zh/introduction/concepts/incompatible-changes.md
+++ b/docs/zh/introduction/concepts/incompatible-changes.md
@@ -4,6 +4,17 @@
 
 ## dev
 
+### Spark:精度超过 38 的 DECIMAL
+
+- **行为变更:在 Spark 引擎上,作为顶层列或 `ROW` 内字段的 `DECIMAL(p, s)`(`p > 38`)不再在构建 schema 
时失败**
+  - **描述**:Spark 的 decimal 类型精度最大为 38,因此此前这类列会在读取任何数据之前报错 `decimal can only 
support precision up to 38`。现在 SeaTunnel 在 Spark 中把这些值作为普通十进制文本传递,在列的 metadata 
中记录声明的精度和小数位数,并为 Transform 和 Sink 还原为 `DECIMAL(p, s)`。
+  - **影响**:
+    - 在 Spark 2.4 或 3.3 上读取这类列的作业现在可以运行,且不丢失任何数字。
+    - `ARRAY` 或 `MAP` 内的 `DECIMAL(p > 38)` 现在会在作业启动时以错误 `SPARK-TRANSLATION-01` 
失败,报错中给出表名、列路径和绕过办法。如果 Doris 源把 `LARGEINT` 声明为 `DECIMAL(39, 0)`,这也适用于 Spark 上 
Doris 的 `ARRAY<LARGEINT>` 列以及 MAP 中的 `LARGEINT` 键和值;此前它们使用 `DECIMAL(20, 0)`,超过 
20 位的值会溢出。
+    - 值超出声明的精度或小数位数时,作业会以 `SPARK-TRANSLATION-02` 失败,不会被四舍五入。
+    - 在 Spark 自己看到的数据中(例如 Dataset 的 schema 或临时视图),这些列是 `STRING`。
+  - **迁移指南**:顶层列和 `ROW` 内的列无需修改。对于 `ARRAY` 或 `MAP` 列,请在 Source 端不读取该列、在 Source 
查询中转换它,或改用 Zeta 或 Flink 引擎运行作业。`Sql` Transform 在这里无效,因为在 Spark 上 Source 的 
schema 在任何 Transform 运行之前就已构建。参见 [SeaTunnel 运行在 Spark 
上](../../engines/spark.md#精度超过-38-的-decimal)。
+
 ### File Sink:XML 应用 `date_format`、`datetime_format` 和 `time_format`
 
 - **行为变更:文件类 Sink 的 XML 写入器按配置的格式输出 DATE、TIME 和 TIMESTAMP**
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-assert-e2e/src/test/java/org/apache/seatunnel/e2e/connector/assertion/FakeSourceToAssertIT.java
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-assert-e2e/src/test/java/org/apache/seatunnel/e2e/connector/assertion/FakeSourceToAssertIT.java
index f366f69b6e..70c2c00f29 100644
--- 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-assert-e2e/src/test/java/org/apache/seatunnel/e2e/connector/assertion/FakeSourceToAssertIT.java
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-assert-e2e/src/test/java/org/apache/seatunnel/e2e/connector/assertion/FakeSourceToAssertIT.java
@@ -69,4 +69,30 @@ public class FakeSourceToAssertIT extends TestSuiteBase {
                 
container.executeJob("/assertion/fake_full_types_to_assert.conf");
         Assertions.assertEquals(0, execResult.getExitCode());
     }
+
+    /** DECIMAL above precision 38 keeps its values and type, also through a 
transform on Spark. */
+    @TestTemplate
+    public void testWideDecimalToAssertSink(TestContainer container)
+            throws IOException, InterruptedException {
+        Container.ExecResult execResult =
+                
container.executeJob("/assertion/fake_wide_decimal_to_assert.conf");
+        Assertions.assertEquals(0, execResult.getExitCode(), 
execResult.getStderr());
+    }
+
+    /** Spark cannot carry DECIMAL above precision 38 inside a MAP and must 
say so at job start. */
+    @TestTemplate
+    public void testWideDecimalInMapToAssertSink(TestContainer container)
+            throws IOException, InterruptedException {
+        Container.ExecResult execResult =
+                
container.executeJob("/assertion/fake_wide_decimal_map_to_assert.conf");
+        if (container.identifier().getEngineType() != EngineType.SPARK) {
+            Assertions.assertEquals(0, execResult.getExitCode(), 
execResult.getStderr());
+            return;
+        }
+        Assertions.assertNotEquals(0, execResult.getExitCode());
+        String output = execResult.getStdout() + execResult.getStderr();
+        Assertions.assertTrue(output.contains("SPARK-TRANSLATION-01"), output);
+        Assertions.assertTrue(output.contains("`c_attrs.value`"), output);
+        Assertions.assertTrue(output.contains("DECIMAL(39, 0)"), output);
+    }
 }
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-assert-e2e/src/test/resources/assertion/fake_wide_decimal_map_to_assert.conf
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-assert-e2e/src/test/resources/assertion/fake_wide_decimal_map_to_assert.conf
new file mode 100644
index 0000000000..47e8b0f608
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-assert-e2e/src/test/resources/assertion/fake_wide_decimal_map_to_assert.conf
@@ -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.
+#
+
+# DECIMAL with precision above 38 as a MAP value. Spark rejects this at job 
start.
+
+env {
+  parallelism = 1
+  job.mode = BATCH
+}
+
+source {
+  FakeSource {
+    schema = {
+      fields {
+        c_id = int
+        c_attrs = "map<string, decimal(39, 0)>"
+      }
+    }
+    rows = [
+      {
+        kind = INSERT
+        fields = [1, { k0 = "170141183460469231731687303715884105727" }]
+      }
+    ]
+    plugin_output = "fake"
+  }
+}
+
+sink {
+  Assert {
+    plugin_input = "fake"
+    rules = {
+      row_rules = [
+        {
+          rule_type = MIN_ROW
+          rule_value = 1
+        }
+      ]
+    }
+  }
+}
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-assert-e2e/src/test/resources/assertion/fake_wide_decimal_to_assert.conf
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-assert-e2e/src/test/resources/assertion/fake_wide_decimal_to_assert.conf
new file mode 100644
index 0000000000..9a58bebef6
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-assert-e2e/src/test/resources/assertion/fake_wide_decimal_to_assert.conf
@@ -0,0 +1,146 @@
+#
+# 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.
+#
+
+# DECIMAL with precision above 38, at the top level and inside a ROW.
+# Spark carries these as text. The sink must still get BigDecimal values with 
the
+# declared scale (checked by field_type) and the exact digits (checked by 
equals_to).
+
+env {
+  parallelism = 1
+  job.mode = BATCH
+}
+
+source {
+  FakeSource {
+    schema = {
+      fields {
+        c_id = int
+        c_max = "decimal(39, 0)"
+        c_min = "decimal(39, 0)"
+        c_zero = "decimal(39, 0)"
+        c_null = "decimal(39, 0)"
+        c_scaled = "decimal(50, 10)"
+        c_narrow = "decimal(10, 2)"
+        c_row = {
+          c_inner_max = "decimal(39, 0)"
+          c_inner_min = "decimal(39, 0)"
+        }
+      }
+    }
+    rows = [
+      {
+        kind = INSERT
+        fields = [1, "170141183460469231731687303715884105727", 
"-170141183460469231731687303715884105728", "0", null, 
"-12345678901234567890123456789012345678.0123456789", "12345678.90", 
["170141183460469231731687303715884105727", 
"-170141183460469231731687303715884105728"]]
+      }
+    ]
+    plugin_output = "fake"
+  }
+}
+
+transform {
+  Sql {
+    plugin_input = "fake"
+    plugin_output = "fake1"
+    query = "select c_id, c_max, c_min, c_zero, c_null, c_scaled, c_narrow, 
c_row from dual"
+  }
+}
+
+sink {
+  Assert {
+    plugin_input = "fake1"
+    rules = {
+      row_rules = [
+        {
+          rule_type = MAX_ROW
+          rule_value = 1
+        },
+        {
+          rule_type = MIN_ROW
+          rule_value = 1
+        }
+      ]
+      field_rules = [
+        {
+          field_name = c_max
+          field_type = "decimal(39, 0)"
+          field_value = [
+            {
+              equals_to = "170141183460469231731687303715884105727"
+            }
+          ]
+        },
+        {
+          field_name = c_min
+          field_type = "decimal(39, 0)"
+          field_value = [
+            {
+              equals_to = "-170141183460469231731687303715884105728"
+            }
+          ]
+        },
+        {
+          field_name = c_zero
+          field_type = "decimal(39, 0)"
+          field_value = [
+            {
+              equals_to = "0"
+            }
+          ]
+        },
+        {
+          field_name = c_null
+          field_type = "decimal(39, 0)"
+          field_value = [
+            {
+              rule_type = NULL
+            }
+          ]
+        },
+        {
+          field_name = c_scaled
+          field_type = "decimal(50, 10)"
+          field_value = [
+            {
+              equals_to = "-12345678901234567890123456789012345678.0123456789"
+            }
+          ]
+        },
+        {
+          field_name = c_narrow
+          field_type = "decimal(10, 2)"
+          field_value = [
+            {
+              equals_to = "12345678.90"
+            }
+          ]
+        },
+        {
+          field_name = c_row
+          field_type = {
+            c_inner_max = "decimal(39, 0)"
+            c_inner_min = "decimal(39, 0)"
+          }
+          field_value = [
+            {
+              equals_to = ["170141183460469231731687303715884105727", 
"-170141183460469231731687303715884105728"]
+            }
+          ]
+        }
+      ]
+    }
+  }
+}
diff --git 
a/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/exception/SparkTranslationErrorCode.java
 
b/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/exception/SparkTranslationErrorCode.java
new file mode 100644
index 0000000000..3473de6041
--- /dev/null
+++ 
b/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/exception/SparkTranslationErrorCode.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.translation.spark.exception;
+
+import org.apache.seatunnel.common.exception.SeaTunnelErrorCode;
+
+/** Error codes of the Spark translation layer. */
+public enum SparkTranslationErrorCode implements SeaTunnelErrorCode {
+    WIDE_DECIMAL_IN_COLLECTION(
+            "SPARK-TRANSLATION-01",
+            "DECIMAL with precision above 38 inside an ARRAY or MAP is not 
supported on Spark"),
+    INVALID_WIDE_DECIMAL_VALUE(
+            "SPARK-TRANSLATION-02",
+            "Value does not match the declared DECIMAL type with precision 
above 38"),
+    INVALID_WIDE_DECIMAL_METADATA(
+            "SPARK-TRANSLATION-03", "Invalid SeaTunnel decimal metadata on a 
Spark schema field");
+
+    private final String code;
+    private final String description;
+
+    SparkTranslationErrorCode(String code, String description) {
+        this.code = code;
+        this.description = description;
+    }
+
+    @Override
+    public String getCode() {
+        return code;
+    }
+
+    @Override
+    public String getDescription() {
+        return description;
+    }
+}
diff --git 
a/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/execution/MultiTableManager.java
 
b/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/execution/MultiTableManager.java
index 48b7d766bf..2d1dd5d933 100644
--- 
a/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/execution/MultiTableManager.java
+++ 
b/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/execution/MultiTableManager.java
@@ -27,6 +27,7 @@ import 
org.apache.seatunnel.translation.spark.serialization.InternalRowCollector
 import 
org.apache.seatunnel.translation.spark.serialization.InternalRowConverter;
 import 
org.apache.seatunnel.translation.spark.serialization.SeaTunnelRowConverter;
 import org.apache.seatunnel.translation.spark.utils.TypeConverterUtils;
+import org.apache.seatunnel.translation.spark.utils.WideDecimalUtils;
 
 import org.apache.spark.sql.catalyst.InternalRow;
 import org.apache.spark.sql.catalyst.expressions.GenericRow;
@@ -58,6 +59,12 @@ public class MultiTableManager implements Serializable {
     private boolean isMultiTable = false;
 
     public MultiTableManager(CatalogTable[] catalogTables) {
+        // Merging renames the columns to column0..N, so check before it to 
name the real column.
+        for (CatalogTable catalogTable : catalogTables) {
+            WideDecimalUtils.checkNoWideDecimalInCollection(
+                    String.valueOf(catalogTable.getTablePath()),
+                    catalogTable.getSeaTunnelRowType());
+        }
         List<ColumnWithIndex> columnWithIndexes = mergeSchema(catalogTables);
         if (catalogTables.length > 1) {
             isMultiTable = true;
diff --git 
a/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/serialization/InternalRowConverter.java
 
b/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/serialization/InternalRowConverter.java
index 0258c52661..bacf29c758 100644
--- 
a/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/serialization/InternalRowConverter.java
+++ 
b/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/serialization/InternalRowConverter.java
@@ -19,6 +19,7 @@ package org.apache.seatunnel.translation.spark.serialization;
 
 import org.apache.seatunnel.api.table.type.ArrayType;
 import org.apache.seatunnel.api.table.type.BasicType;
+import org.apache.seatunnel.api.table.type.DecimalType;
 import org.apache.seatunnel.api.table.type.MapType;
 import org.apache.seatunnel.api.table.type.RowKind;
 import org.apache.seatunnel.api.table.type.SeaTunnelDataType;
@@ -28,6 +29,7 @@ import 
org.apache.seatunnel.translation.serialization.RowConverter;
 import org.apache.seatunnel.translation.spark.utils.InstantConverterUtils;
 import org.apache.seatunnel.translation.spark.utils.OffsetDateTimeUtils;
 import org.apache.seatunnel.translation.spark.utils.TypeConverterUtils;
+import org.apache.seatunnel.translation.spark.utils.WideDecimalUtils;
 
 import org.apache.spark.sql.catalyst.InternalRow;
 import org.apache.spark.sql.catalyst.expressions.MutableAny;
@@ -90,6 +92,14 @@ public final class InternalRowConverter extends 
RowConverter<InternalRow> {
     }
 
     private static Object convert(Object field, SeaTunnelDataType<?> dataType) 
{
+        return convert(field, dataType, null);
+    }
+
+    /**
+     * @param column the field name, used in error messages; {@code null} 
inside an ARRAY or MAP. A
+     *     nested ROW adds its own name only when an error passes through it.
+     */
+    private static Object convert(Object field, SeaTunnelDataType<?> dataType, 
String column) {
         if (field == null) {
             return null;
         }
@@ -97,7 +107,11 @@ public final class InternalRowConverter extends 
RowConverter<InternalRow> {
             case ROW:
                 SeaTunnelRow seaTunnelRow = (SeaTunnelRow) field;
                 SeaTunnelRowType rowType = (SeaTunnelRowType) dataType;
-                return convert(seaTunnelRow, rowType);
+                try {
+                    return convert(seaTunnelRow, rowType);
+                } catch (WideDecimalUtils.InvalidWideDecimalException e) {
+                    throw e.withParent(column);
+                }
             case DATE:
                 return (int) ((LocalDate) field).toEpochDay();
             case TIME:
@@ -112,6 +126,11 @@ public final class InternalRowConverter extends 
RowConverter<InternalRow> {
             case STRING:
                 return UTF8String.fromString((String) field);
             case DECIMAL:
+                if (WideDecimalUtils.isWideDecimal(dataType)) {
+                    return UTF8String.fromString(
+                            WideDecimalUtils.toCarrierText(
+                                    (BigDecimal) field, (DecimalType) 
dataType, column));
+                }
                 return Decimal.apply((BigDecimal) field);
             case ARRAY:
                 SeaTunnelDataType<?> elementType = ((ArrayType<?, ?>) 
dataType).getElementType();
@@ -155,7 +174,11 @@ public final class InternalRowConverter extends 
RowConverter<InternalRow> {
             if 
(TypeConverterUtils.ROW_KIND_FIELD.equals(rowType.getFieldName(i))) {
                 values[i].update(seaTunnelRow.getRowKind().toByteValue());
             } else {
-                Object fieldValue = convert(seaTunnelRow.getField(i), 
rowType.getFieldType(i));
+                Object fieldValue =
+                        convert(
+                                seaTunnelRow.getField(i),
+                                rowType.getFieldType(i),
+                                rowType.getFieldName(i));
                 if (fieldValue != null) {
                     values[i].update(fieldValue);
                 }
@@ -170,7 +193,11 @@ public final class InternalRowConverter extends 
RowConverter<InternalRow> {
         MutableValue[] values = new MutableValue[arity + 2];
         for (int i = 0; i < indexes.length; i++) {
             values[indexes[i] + 2] = 
createMutableValue(rowType.getFieldType(indexes[i]));
-            Object fieldValue = convert(seaTunnelRow.getField(i), 
rowType.getFieldType(indexes[i]));
+            Object fieldValue =
+                    convert(
+                            seaTunnelRow.getField(i),
+                            rowType.getFieldType(indexes[i]),
+                            rowType.getFieldName(indexes[i]));
             if (fieldValue != null) {
                 values[indexes[i] + 2].update(fieldValue);
             }
@@ -275,7 +302,8 @@ public final class InternalRowConverter extends 
RowConverter<InternalRow> {
                             engineRow.get(
                                     indexes[i] + 2,
                                     
TypeConverterUtils.convert(rowType.getFieldType(indexes[i]))),
-                            rowType.getFieldType(indexes[i]));
+                            rowType.getFieldType(indexes[i]),
+                            rowType.getFieldName(indexes[i]));
         }
         SeaTunnelRow seaTunnelRow = new SeaTunnelRow(fields);
         seaTunnelRow.setRowKind(rowKind);
@@ -289,12 +317,24 @@ public final class InternalRowConverter extends 
RowConverter<InternalRow> {
     }
 
     private static Object reconvert(Object field, SeaTunnelDataType<?> 
dataType) {
+        return reconvert(field, dataType, null);
+    }
+
+    /**
+     * @param column the field name, used in error messages; {@code null} 
inside an ARRAY or MAP. A
+     *     nested ROW adds its own name only when an error passes through it.
+     */
+    private static Object reconvert(Object field, SeaTunnelDataType<?> 
dataType, String column) {
         if (field == null) {
             return null;
         }
         switch (dataType.getSqlType()) {
             case ROW:
-                return reconvert((InternalRow) field, (SeaTunnelRowType) 
dataType);
+                try {
+                    return reconvert((InternalRow) field, (SeaTunnelRowType) 
dataType);
+                } catch (WideDecimalUtils.InvalidWideDecimalException e) {
+                    throw e.withParent(column);
+                }
             case DATE:
                 if (field instanceof Date) {
                     return ((Date) field).toLocalDate();
@@ -333,11 +373,19 @@ public final class InternalRowConverter extends 
RowConverter<InternalRow> {
             case STRING:
                 return field.toString();
             case DECIMAL:
-                if (field instanceof Decimal) {
+                if (WideDecimalUtils.isWideDecimal(dataType)
+                        && (field instanceof UTF8String || field instanceof 
String)) {
+                    return WideDecimalUtils.fromCarrierText(
+                            field.toString(), (DecimalType) dataType, column);
+                } else if (field instanceof Decimal) {
                     return ((Decimal) field).toJavaBigDecimal();
                 } else if (field instanceof BigDecimal) {
                     return field;
                 }
+                throw new IllegalArgumentException(
+                        String.format(
+                                "Unexpected Spark value of type %s for 
SeaTunnel %s",
+                                field.getClass().getName(), dataType));
             case ARRAY:
                 if (field instanceof ArrayData) {
                     return reconvertArray((ArrayData) field, (ArrayType<?, ?>) 
dataType);
@@ -361,7 +409,8 @@ public final class InternalRowConverter extends 
RowConverter<InternalRow> {
             fields[i] =
                     reconvert(
                             engineRow.get(i, 
TypeConverterUtils.convert(rowType.getFieldType(i))),
-                            rowType.getFieldType(i));
+                            rowType.getFieldType(i),
+                            rowType.getFieldName(i));
         }
         return new SeaTunnelRow(fields);
     }
diff --git 
a/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/serialization/SeaTunnelRowConverter.java
 
b/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/serialization/SeaTunnelRowConverter.java
index 1d03b6448d..36b8ddae85 100644
--- 
a/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/serialization/SeaTunnelRowConverter.java
+++ 
b/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/serialization/SeaTunnelRowConverter.java
@@ -19,6 +19,7 @@ package org.apache.seatunnel.translation.spark.serialization;
 
 import org.apache.seatunnel.api.table.type.ArrayType;
 import org.apache.seatunnel.api.table.type.BasicType;
+import org.apache.seatunnel.api.table.type.DecimalType;
 import org.apache.seatunnel.api.table.type.MapType;
 import org.apache.seatunnel.api.table.type.RowKind;
 import org.apache.seatunnel.api.table.type.SeaTunnelDataType;
@@ -27,6 +28,7 @@ import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
 import org.apache.seatunnel.api.table.type.SqlType;
 import org.apache.seatunnel.translation.serialization.RowConverter;
 import org.apache.seatunnel.translation.spark.utils.OffsetDateTimeUtils;
+import org.apache.seatunnel.translation.spark.utils.WideDecimalUtils;
 
 import org.apache.spark.sql.catalyst.expressions.GenericRow;
 import org.apache.spark.unsafe.types.UTF8String;
@@ -76,7 +78,11 @@ public class SeaTunnelRowConverter extends 
RowConverter<GenericRow> {
         fields[0] = seaTunnelRow.getRowKind().toByteValue();
         fields[1] = seaTunnelRow.getTableId();
         for (int i = 0; i < indexes.length; i++) {
-            Object fieldValue = convert(seaTunnelRow.getField(i), 
rowType.getFieldType(indexes[i]));
+            Object fieldValue =
+                    convert(
+                            seaTunnelRow.getField(i),
+                            rowType.getFieldType(indexes[i]),
+                            rowType.getFieldName(indexes[i]));
             if (fieldValue != null) {
                 fields[indexes[i] + 2] = fieldValue;
             }
@@ -85,6 +91,14 @@ public class SeaTunnelRowConverter extends 
RowConverter<GenericRow> {
     }
 
     private Object convert(Object field, SeaTunnelDataType<?> dataType) {
+        return convert(field, dataType, null);
+    }
+
+    /**
+     * @param column the field name, used in error messages; {@code null} 
inside an ARRAY or MAP. A
+     *     nested ROW adds its own name only when an error passes through it.
+     */
+    private Object convert(Object field, SeaTunnelDataType<?> dataType, String 
column) {
         if (field == null) {
             return null;
         }
@@ -92,7 +106,17 @@ public class SeaTunnelRowConverter extends 
RowConverter<GenericRow> {
             case ROW:
                 SeaTunnelRow seaTunnelRow = (SeaTunnelRow) field;
                 SeaTunnelRowType rowType = (SeaTunnelRowType) dataType;
-                return convertRow(seaTunnelRow, rowType);
+                try {
+                    return convertRow(seaTunnelRow, rowType);
+                } catch (WideDecimalUtils.InvalidWideDecimalException e) {
+                    throw e.withParent(column);
+                }
+            case DECIMAL:
+                if (WideDecimalUtils.isWideDecimal(dataType)) {
+                    return WideDecimalUtils.toCarrierText(
+                            (BigDecimal) field, (DecimalType) dataType, 
column);
+                }
+                return field;
             case DATE:
                 return Date.valueOf((LocalDate) field);
             case TIMESTAMP:
@@ -138,7 +162,11 @@ public class SeaTunnelRowConverter extends 
RowConverter<GenericRow> {
         int arity = rowType.getTotalFields();
         Object[] values = new Object[arity];
         for (int i = 0; i < arity; i++) {
-            Object fieldValue = convert(seaTunnelRow.getField(i), 
rowType.getFieldType(i));
+            Object fieldValue =
+                    convert(
+                            seaTunnelRow.getField(i),
+                            rowType.getFieldType(i),
+                            rowType.getFieldName(i));
             if (fieldValue != null) {
                 values[i] = fieldValue;
             }
@@ -197,7 +225,11 @@ public class SeaTunnelRowConverter extends 
RowConverter<GenericRow> {
         Object[] fields = new Object[indexes.length];
         for (int i = 0; i < indexes.length; i++) {
             int fieldIndex = indexes[i];
-            fields[i] = reconvert(engineRow.get(fieldIndex + 2), 
rowType.getFieldType(fieldIndex));
+            fields[i] =
+                    reconvert(
+                            engineRow.get(fieldIndex + 2),
+                            rowType.getFieldType(fieldIndex),
+                            rowType.getFieldName(fieldIndex));
         }
         SeaTunnelRow seaTunnelRow = new SeaTunnelRow(fields);
         seaTunnelRow.setRowKind(rowKind);
@@ -206,15 +238,35 @@ public class SeaTunnelRowConverter extends 
RowConverter<GenericRow> {
     }
 
     private Object reconvert(Object field, SeaTunnelDataType<?> dataType) {
+        return reconvert(field, dataType, null);
+    }
+
+    /**
+     * @param column the field name, used in error messages; {@code null} 
inside an ARRAY or MAP. A
+     *     nested ROW adds its own name only when an error passes through it.
+     */
+    private Object reconvert(Object field, SeaTunnelDataType<?> dataType, 
String column) {
         if (field == null) {
             return null;
         }
         switch (dataType.getSqlType()) {
             case ROW:
-                if (field instanceof GenericRow) {
-                    return createFromGenericRow((GenericRow) field, 
(SeaTunnelRowType) dataType);
+                try {
+                    if (field instanceof GenericRow) {
+                        return createFromGenericRow(
+                                (GenericRow) field, (SeaTunnelRowType) 
dataType);
+                    }
+                    return reconvert((SeaTunnelRow) field, (SeaTunnelRowType) 
dataType);
+                } catch (WideDecimalUtils.InvalidWideDecimalException e) {
+                    throw e.withParent(column);
+                }
+            case DECIMAL:
+                if (WideDecimalUtils.isWideDecimal(dataType)
+                        && (field instanceof String || field instanceof 
UTF8String)) {
+                    return WideDecimalUtils.fromCarrierText(
+                            field.toString(), (DecimalType) dataType, column);
                 }
-                return reconvert((SeaTunnelRow) field, (SeaTunnelRowType) 
dataType);
+                return field;
             case DATE:
                 return ((Date) field).toLocalDate();
             case TIMESTAMP:
@@ -241,7 +293,7 @@ public class SeaTunnelRowConverter extends 
RowConverter<GenericRow> {
         Object[] fields = row.values();
         Object[] newFields = new Object[fields.length];
         for (int idx = 0; idx < fields.length; idx++) {
-            newFields[idx] = reconvert(fields[idx], type.getFieldType(idx));
+            newFields[idx] = reconvert(fields[idx], type.getFieldType(idx), 
type.getFieldName(idx));
         }
         return new SeaTunnelRow(newFields);
     }
@@ -250,7 +302,11 @@ public class SeaTunnelRowConverter extends 
RowConverter<GenericRow> {
         int num = engineRow.getFields().length;
         Object[] fields = new Object[num];
         for (int i = 0; i < num; i++) {
-            fields[i] = reconvert(engineRow.getFields()[i], 
rowType.getFieldType(i));
+            fields[i] =
+                    reconvert(
+                            engineRow.getFields()[i],
+                            rowType.getFieldType(i),
+                            rowType.getFieldName(i));
         }
         return new SeaTunnelRow(fields);
     }
diff --git 
a/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/utils/TypeConverterUtils.java
 
b/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/utils/TypeConverterUtils.java
index 45ead72e6a..13a601c216 100644
--- 
a/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/utils/TypeConverterUtils.java
+++ 
b/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/utils/TypeConverterUtils.java
@@ -68,6 +68,16 @@ public class TypeConverterUtils {
     }
 
     public static DataType convert(SeaTunnelDataType<?> dataType) {
+        return convert(dataType, null);
+    }
+
+    /**
+     * @param collectionPart {@code "element"}, {@code "key"} or {@code 
"value"} inside an ARRAY or
+     *     MAP, else {@code null}. A wide decimal there fails. {@code 
MultiTableManager} already
+     *     rejects it with the table and column at job start; this only keeps 
other callers safe
+     *     without walking the type tree again.
+     */
+    private static DataType convert(SeaTunnelDataType<?> dataType, String 
collectionPart) {
         checkNotNull(dataType, "The SeaTunnel's data type is required.");
         switch (dataType.getSqlType()) {
             case NULL:
@@ -100,23 +110,32 @@ public class TypeConverterUtils {
                 return OffsetDateTimeUtils.OFFSET_DATETIME_WITH_DECIMAL;
             case ARRAY:
                 return DataTypes.createArrayType(
-                        convert(((ArrayType<?, ?>) 
dataType).getElementType()));
+                        convert(((ArrayType<?, ?>) dataType).getElementType(), 
"element"));
             case MAP:
                 MapType<?, ?> mapType = (MapType<?, ?>) dataType;
                 return DataTypes.createMapType(
-                        convert(mapType.getKeyType()), 
convert(mapType.getValueType()));
+                        convert(mapType.getKeyType(), "key"),
+                        convert(mapType.getValueType(), "value"));
             case DECIMAL:
+                if (WideDecimalUtils.isWideDecimal(dataType)) {
+                    if (collectionPart != null) {
+                        throw WideDecimalUtils.collectionError(
+                                null, collectionPart, (DecimalType) dataType);
+                    }
+                    // Spark stops at precision 38; the field metadata keeps 
the declared type.
+                    return DataTypes.StringType;
+                }
                 DecimalType decimalType = (DecimalType) dataType;
                 return new org.apache.spark.sql.types.DecimalType(
                         decimalType.getPrecision(), decimalType.getScale());
             case ROW:
-                return convert((SeaTunnelRowType) dataType);
+                return convert((SeaTunnelRowType) dataType, collectionPart);
             default:
         }
         throw new IllegalArgumentException("Unsupported SeaTunnel's data type: 
" + dataType);
     }
 
-    private static StructType convert(SeaTunnelRowType rowType) {
+    private static StructType convert(SeaTunnelRowType rowType, String 
collectionPart) {
         // TODO: row kind
         StructField[] fields = new StructField[rowType.getFieldNames().length];
         for (int i = 0; i < rowType.getFieldNames().length; i++) {
@@ -131,12 +150,18 @@ public class TypeConverterUtils {
                                         
OffsetDateTimeUtils.LOGICAL_TIMESTAMP_WITH_OFFSET_TYPE_FLAG,
                                         true)
                                 .build();
+            } else if (WideDecimalUtils.isWideDecimal(fieldType)) {
+                metadata = WideDecimalUtils.metadata((DecimalType) fieldType);
             } else {
                 metadata = Metadata.empty();
             }
 
             fields[i] =
-                    new StructField(rowType.getFieldNames()[i], 
convert(fieldType), true, metadata);
+                    new StructField(
+                            rowType.getFieldNames()[i],
+                            convert(fieldType, collectionPart),
+                            true,
+                            metadata);
         }
         return new StructType(fields);
     }
@@ -210,7 +235,10 @@ public class TypeConverterUtils {
         for (int i = 0; i < structFields.length; i++) {
             fieldNames[i] = structFields[i].name();
             Metadata metadata = structFields[i].metadata();
-            if (metadata != null
+            DecimalType wideDecimalType = 
WideDecimalUtils.fromMetadata(structFields[i]);
+            if (wideDecimalType != null) {
+                fieldTypes[i] = wideDecimalType;
+            } else if (metadata != null
                     && metadata.contains(LOGICAL_TIME_TYPE_FLAG)
                     && metadata.getBoolean(LOGICAL_TIME_TYPE_FLAG)) {
                 fieldTypes[i] = LocalTimeType.LOCAL_TIME_TYPE;
diff --git 
a/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/utils/WideDecimalUtils.java
 
b/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/utils/WideDecimalUtils.java
new file mode 100644
index 0000000000..0175f91755
--- /dev/null
+++ 
b/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/main/java/org/apache/seatunnel/translation/spark/utils/WideDecimalUtils.java
@@ -0,0 +1,285 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.translation.spark.utils;
+
+import org.apache.seatunnel.api.table.type.ArrayType;
+import org.apache.seatunnel.api.table.type.DecimalType;
+import org.apache.seatunnel.api.table.type.MapType;
+import org.apache.seatunnel.api.table.type.SeaTunnelDataType;
+import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
+import org.apache.seatunnel.common.exception.SeaTunnelRuntimeException;
+import 
org.apache.seatunnel.translation.spark.exception.SparkTranslationErrorCode;
+
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.Metadata;
+import org.apache.spark.sql.types.MetadataBuilder;
+import org.apache.spark.sql.types.StructField;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.regex.Pattern;
+
+/**
+ * Carries SeaTunnel {@code DECIMAL(p, s)} with {@code p > 38} through Spark, 
whose decimal type
+ * stops at precision 38.
+ *
+ * <p>A wide decimal at the top level or inside a {@code ROW} becomes a Spark 
{@code StringType}
+ * field with the declared precision and scale in the field metadata. The 
value is its plain decimal
+ * text. A wide decimal inside an {@code ARRAY} or {@code MAP} has no field to 
hold the metadata, so
+ * it is rejected before the job reads any data.
+ */
+public final class WideDecimalUtils {
+
+    public static final int SPARK_MAX_PRECISION = 38;
+    public static final String PRECISION_KEY = "seatunnel_decimal_precision";
+    public static final String SCALE_KEY = "seatunnel_decimal_scale";
+
+    private static final Pattern PLAIN_DECIMAL = 
Pattern.compile("-?\\d+(\\.\\d+)?");
+    private static final int MAX_VALUE_LENGTH_IN_MESSAGE = 64;
+
+    private WideDecimalUtils() {
+        throw new UnsupportedOperationException(
+                "WideDecimalUtils is a utility class and cannot be 
instantiated");
+    }
+
+    public static boolean isWideDecimal(SeaTunnelDataType<?> dataType) {
+        return dataType instanceof DecimalType
+                && ((DecimalType) dataType).getPrecision() > 
SPARK_MAX_PRECISION;
+    }
+
+    public static Metadata metadata(DecimalType decimalType) {
+        return new MetadataBuilder()
+                .putLong(PRECISION_KEY, decimalType.getPrecision())
+                .putLong(SCALE_KEY, decimalType.getScale())
+                .build();
+    }
+
+    /**
+     * Restores the SeaTunnel type of a carrier field.
+     *
+     * @return the decimal type, or {@code null} if the field carries no 
decimal metadata
+     */
+    public static DecimalType fromMetadata(StructField field) {
+        Metadata metadata = field.metadata();
+        boolean hasPrecision = metadata != null && 
metadata.contains(PRECISION_KEY);
+        boolean hasScale = metadata != null && metadata.contains(SCALE_KEY);
+        if (!hasPrecision && !hasScale) {
+            return null;
+        }
+        if (!hasPrecision || !hasScale) {
+            throw invalidMetadata(field, "both keys must be present");
+        }
+        if (!DataTypes.StringType.equals(field.dataType())) {
+            throw invalidMetadata(field, "the field type must be STRING");
+        }
+        long precision;
+        long scale;
+        try {
+            precision = metadata.getLong(PRECISION_KEY);
+            scale = metadata.getLong(SCALE_KEY);
+        } catch (RuntimeException e) {
+            throw invalidMetadata(field, "both values must be integers");
+        }
+        if (precision <= SPARK_MAX_PRECISION
+                || precision > Integer.MAX_VALUE
+                || scale < 0
+                || scale > precision) {
+            throw invalidMetadata(
+                    field,
+                    String.format(
+                            "precision must be above %d and scale between 0 
and precision",
+                            SPARK_MAX_PRECISION));
+        }
+        return new DecimalType((int) precision, (int) scale);
+    }
+
+    /**
+     * Fails if a wide decimal sits inside an {@code ARRAY} or {@code MAP} 
anywhere in the row type.
+     * Call it once per source table, before the tables are merged, so that 
the error can name the
+     * real table and column.
+     *
+     * @param tablePath the table path for the error message, or {@code null} 
if unknown
+     */
+    public static void checkNoWideDecimalInCollection(String tablePath, 
SeaTunnelRowType rowType) {
+        for (int i = 0; i < rowType.getTotalFields(); i++) {
+            check(tablePath, rowType.getFieldName(i), rowType.getFieldType(i), 
false);
+        }
+    }
+
+    private static void check(
+            String tablePath, String path, SeaTunnelDataType<?> dataType, 
boolean inCollection) {
+        switch (dataType.getSqlType()) {
+            case DECIMAL:
+                if (inCollection && isWideDecimal(dataType)) {
+                    throw collectionError(tablePath, path, (DecimalType) 
dataType);
+                }
+                return;
+            case ROW:
+                SeaTunnelRowType rowType = (SeaTunnelRowType) dataType;
+                for (int i = 0; i < rowType.getTotalFields(); i++) {
+                    check(
+                            tablePath,
+                            path + "." + rowType.getFieldName(i),
+                            rowType.getFieldType(i),
+                            inCollection);
+                }
+                return;
+            case ARRAY:
+                check(
+                        tablePath,
+                        path + ".element",
+                        ((ArrayType<?, ?>) dataType).getElementType(),
+                        true);
+                return;
+            case MAP:
+                MapType<?, ?> mapType = (MapType<?, ?>) dataType;
+                check(tablePath, path + ".key", mapType.getKeyType(), true);
+                check(tablePath, path + ".value", mapType.getValueType(), 
true);
+                return;
+            default:
+        }
+    }
+
+    /** SeaTunnel value to carrier text. Pads the scale but never rounds or 
narrows. */
+    public static String toCarrierText(BigDecimal value, DecimalType 
decimalType, String column) {
+        BigDecimal scaled;
+        try {
+            scaled = value.setScale(decimalType.getScale(), 
RoundingMode.UNNECESSARY);
+        } catch (ArithmeticException e) {
+            throw invalidValue(
+                    column,
+                    decimalType,
+                    value.toPlainString(),
+                    "it has more than " + decimalType.getScale() + " fraction 
digits");
+        }
+        if (scaled.precision() > decimalType.getPrecision()) {
+            throw invalidValue(
+                    column,
+                    decimalType,
+                    value.toPlainString(),
+                    "it has more than " + decimalType.getPrecision() + " 
digits");
+        }
+        return scaled.toPlainString();
+    }
+
+    /** Carrier text to SeaTunnel value. Requires the exact declared scale and 
never rescales. */
+    public static BigDecimal fromCarrierText(String text, DecimalType 
decimalType, String column) {
+        if (!PLAIN_DECIMAL.matcher(text).matches()) {
+            throw invalidValue(column, decimalType, text, "it is not plain 
decimal text");
+        }
+        BigDecimal value = new BigDecimal(text);
+        if (value.scale() != decimalType.getScale()) {
+            throw invalidValue(
+                    column,
+                    decimalType,
+                    text,
+                    "its scale is " + value.scale() + ", expected " + 
decimalType.getScale());
+        }
+        if (value.precision() > decimalType.getPrecision()) {
+            throw invalidValue(
+                    column,
+                    decimalType,
+                    text,
+                    "it has more than " + decimalType.getPrecision() + " 
digits");
+        }
+        return value;
+    }
+
+    static String typeName(DecimalType decimalType) {
+        return String.format("DECIMAL(%d, %d)", decimalType.getPrecision(), 
decimalType.getScale());
+    }
+
+    static SeaTunnelRuntimeException collectionError(
+            String tablePath, String columnPath, DecimalType decimalType) {
+        String table = tablePath == null ? "" : String.format(" of table 
`%s`", tablePath);
+        return new SeaTunnelRuntimeException(
+                SparkTranslationErrorCode.WIDE_DECIMAL_IN_COLLECTION,
+                String.format(
+                        "Column `%s`%s has type %s inside an ARRAY/MAP. "
+                                + "Spark supports DECIMAL precision up to %d, "
+                                + "and SeaTunnel cannot carry wider decimals 
inside ARRAY/MAP on Spark. "
+                                + "Workarounds: leave the column out at the 
source "
+                                + "(e.g. Doris `doris.read.field`, or a JDBC 
`query` that omits it), "
+                                + "cast it to a string in the source query, "
+                                + "or run this job on the Zeta or Flink 
engine.",
+                        columnPath, table, typeName(decimalType), 
SPARK_MAX_PRECISION));
+    }
+
+    private static InvalidWideDecimalException invalidValue(
+            String column, DecimalType decimalType, String value, String 
reason) {
+        return new InvalidWideDecimalException(column, decimalType, 
abbreviate(value), reason);
+    }
+
+    private static SeaTunnelRuntimeException invalidMetadata(StructField 
field, String reason) {
+        return new SeaTunnelRuntimeException(
+                SparkTranslationErrorCode.INVALID_WIDE_DECIMAL_METADATA,
+                String.format(
+                        "Field `%s` of type %s has metadata %s, but %s.",
+                        field.name(), field.dataType().sql(), 
field.metadata().json(), reason));
+    }
+
+    /**
+     * A wide decimal value that cannot be carried. The converters pass only 
the field name down and
+     * each enclosing {@code ROW} adds its name on the way up, so the column 
path is built only when
+     * a value fails.
+     */
+    public static final class InvalidWideDecimalException extends 
SeaTunnelRuntimeException {
+
+        private static final long serialVersionUID = 1L;
+
+        private final String column;
+        private final DecimalType decimalType;
+        private final String value;
+        private final String reason;
+
+        private InvalidWideDecimalException(
+                String column, DecimalType decimalType, String value, String 
reason) {
+            super(
+                    SparkTranslationErrorCode.INVALID_WIDE_DECIMAL_VALUE,
+                    String.format(
+                            "Column `%s` declared as %s: value '%s' is 
rejected because %s.",
+                            column, typeName(decimalType), value, reason));
+            this.column = column;
+            this.decimalType = decimalType;
+            this.value = value;
+            this.reason = reason;
+        }
+
+        /** @return the same error with {@code parent} prepended to the column 
path */
+        public InvalidWideDecimalException withParent(String parent) {
+            if (parent == null) {
+                return this;
+            }
+            InvalidWideDecimalException e =
+                    new InvalidWideDecimalException(
+                            column == null ? parent : parent + "." + column,
+                            decimalType,
+                            value,
+                            reason);
+            e.setStackTrace(getStackTrace());
+            return e;
+        }
+    }
+
+    private static String abbreviate(String value) {
+        if (value.length() <= MAX_VALUE_LENGTH_IN_MESSAGE) {
+            return value;
+        }
+        return value.substring(0, MAX_VALUE_LENGTH_IN_MESSAGE) + "...";
+    }
+}
diff --git 
a/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/test/java/org/apache/seatunnel/translation/spark/utils/WideDecimalTest.java
 
b/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/test/java/org/apache/seatunnel/translation/spark/utils/WideDecimalTest.java
new file mode 100644
index 0000000000..fadfd2b42d
--- /dev/null
+++ 
b/seatunnel-translation/seatunnel-translation-spark/seatunnel-translation-spark-common/src/test/java/org/apache/seatunnel/translation/spark/utils/WideDecimalTest.java
@@ -0,0 +1,536 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.translation.spark.utils;
+
+import org.apache.seatunnel.api.table.catalog.CatalogTable;
+import org.apache.seatunnel.api.table.catalog.CatalogTableUtil;
+import org.apache.seatunnel.api.table.type.ArrayType;
+import org.apache.seatunnel.api.table.type.BasicType;
+import org.apache.seatunnel.api.table.type.DecimalType;
+import org.apache.seatunnel.api.table.type.MapType;
+import org.apache.seatunnel.api.table.type.SeaTunnelDataType;
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
+import org.apache.seatunnel.common.exception.SeaTunnelRuntimeException;
+import 
org.apache.seatunnel.translation.spark.exception.SparkTranslationErrorCode;
+import org.apache.seatunnel.translation.spark.execution.MultiTableManager;
+import 
org.apache.seatunnel.translation.spark.serialization.InternalRowConverter;
+import 
org.apache.seatunnel.translation.spark.serialization.SeaTunnelRowConverter;
+
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.catalyst.InternalRow;
+import org.apache.spark.sql.catalyst.analysis.SimpleAnalyzer$;
+import org.apache.spark.sql.catalyst.encoders.ExpressionEncoder;
+import org.apache.spark.sql.catalyst.encoders.RowEncoder;
+import org.apache.spark.sql.catalyst.expressions.GenericRow;
+import org.apache.spark.sql.catalyst.expressions.MutableAny;
+import org.apache.spark.sql.catalyst.expressions.MutableByte;
+import org.apache.spark.sql.catalyst.expressions.MutableValue;
+import org.apache.spark.sql.catalyst.expressions.SpecificInternalRow;
+import org.apache.spark.sql.types.DataType;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.Metadata;
+import org.apache.spark.sql.types.MetadataBuilder;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+import org.apache.spark.unsafe.types.UTF8String;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.List;
+
+class WideDecimalTest {
+
+    private static final BigDecimal INT128_MIN =
+            new BigDecimal("-170141183460469231731687303715884105728");
+    private static final BigDecimal INT128_MAX =
+            new BigDecimal("170141183460469231731687303715884105727");
+    private static final BigDecimal SCALED =
+            new 
BigDecimal("-12345678901234567890123456789012345678.0123456789");
+
+    private static final DecimalType DECIMAL_39_0 = new DecimalType(39, 0);
+    private static final DecimalType DECIMAL_50_10 = new DecimalType(50, 10);
+    private static final DecimalType DECIMAL_10_2 = new DecimalType(10, 2);
+
+    private static final SeaTunnelRowType INFO_TYPE =
+            new SeaTunnelRowType(
+                    new String[] {"inner_big", "name"},
+                    new SeaTunnelDataType<?>[] {DECIMAL_39_0, 
BasicType.STRING_TYPE});
+
+    /** Wide decimals at the top level and inside a ROW, next to an ordinary 
decimal. */
+    private static final SeaTunnelRowType ROW_TYPE =
+            new SeaTunnelRowType(
+                    new String[] {"id", "big", "scaled", "narrow", "info"},
+                    new SeaTunnelDataType<?>[] {
+                        BasicType.INT_TYPE, DECIMAL_39_0, DECIMAL_50_10, 
DECIMAL_10_2, INFO_TYPE
+                    });
+
+    private static List<SeaTunnelRow> sampleRows() {
+        return Arrays.asList(
+                row(1, INT128_MAX, SCALED, new BigDecimal("12345678.90"), 
INT128_MIN),
+                row(2, INT128_MIN, SCALED.negate(), new BigDecimal("-0.01"), 
INT128_MAX),
+                row(3, BigDecimal.ZERO, new BigDecimal("0E-10"), 
BigDecimal.ZERO.setScale(2), null),
+                row(4, null, null, null, null));
+    }
+
+    private static SeaTunnelRow row(
+            int id, BigDecimal big, BigDecimal scaled, BigDecimal narrow, 
BigDecimal innerBig) {
+        SeaTunnelRow info = new SeaTunnelRow(new Object[] {innerBig, "n" + 
id});
+        SeaTunnelRow row = new SeaTunnelRow(new Object[] {id, big, scaled, 
narrow, info});
+        row.setTableId("db.t");
+        return row;
+    }
+
+    @Test
+    void schemaUsesStringCarrierWithMetadata() {
+        StructType schema = (StructType) TypeConverterUtils.convert(ROW_TYPE);
+
+        assertCarrier(schema.apply("big"), 39, 0);
+        assertCarrier(schema.apply("scaled"), 50, 10);
+        Assertions.assertEquals(
+                new org.apache.spark.sql.types.DecimalType(10, 2),
+                schema.apply("narrow").dataType());
+        Assertions.assertEquals(Metadata.empty(), 
schema.apply("narrow").metadata());
+
+        StructType info = (StructType) schema.apply("info").dataType();
+        assertCarrier(info.apply("inner_big"), 39, 0);
+    }
+
+    @Test
+    void schemaRoundTripKeepsDecimalType() {
+        DataType sparkType = TypeConverterUtils.convert(ROW_TYPE);
+        Assertions.assertEquals(ROW_TYPE, 
TypeConverterUtils.convert(sparkType));
+    }
+
+    @Test
+    void parcelledSchemaKeepsCarrier() {
+        StructType schema = (StructType) TypeConverterUtils.parcel(ROW_TYPE);
+        assertCarrier(schema.apply("big"), 39, 0);
+    }
+
+    @Test
+    void internalRowConverterRoundTrip() throws IOException {
+        InternalRowConverter converter = new InternalRowConverter(ROW_TYPE);
+        for (SeaTunnelRow row : sampleRows()) {
+            InternalRow internalRow = converter.convert(row);
+            Object big = internalRow.get(3, DataTypes.StringType);
+            if (row.getField(1) == null) {
+                Assertions.assertTrue(internalRow.isNullAt(3));
+            } else {
+                Assertions.assertEquals(
+                        UTF8String.fromString(((BigDecimal) 
row.getField(1)).toPlainString()), big);
+            }
+            assertRowEquals(row, converter.reconvert(internalRow));
+        }
+    }
+
+    @Test
+    void seaTunnelRowConverterRoundTripThroughRowEncoder() throws IOException {
+        SeaTunnelRowConverter converter = new SeaTunnelRowConverter(ROW_TYPE);
+        StructType schema = (StructType) TypeConverterUtils.parcel(ROW_TYPE);
+        ExpressionEncoder<Row> encoder = rowEncoder(schema);
+        for (SeaTunnelRow row : sampleRows()) {
+            GenericRow genericRow = converter.convert(row);
+            if (row.getField(1) != null) {
+                Assertions.assertEquals(
+                        ((BigDecimal) row.getField(1)).toPlainString(), 
genericRow.get(3));
+            }
+            // The transform path hands these rows to Spark through a 
RowEncoder.
+            InternalRow encoded = 
encoder.createSerializer().apply(genericRow).copy();
+            Row decoded = encoder.createDeserializer().apply(encoded);
+            assertRowEquals(row, converter.reconvert((GenericRow) decoded));
+        }
+    }
+
+    @Test
+    void writePadsScaleWithoutRounding() throws IOException {
+        SeaTunnelRow row = row(5, new BigDecimal("7"), new BigDecimal("5"), 
null, null);
+        InternalRowConverter converter = new InternalRowConverter(ROW_TYPE);
+        InternalRow internalRow = converter.convert(row);
+        Assertions.assertEquals(
+                UTF8String.fromString("5.0000000000"), internalRow.get(4, 
DataTypes.StringType));
+        Assertions.assertEquals(
+                new BigDecimal("5.0000000000"), 
converter.reconvert(internalRow).getField(2));
+    }
+
+    @Test
+    void writeRejectsRoundingAndOverflow() {
+        InternalRowConverter converter = new InternalRowConverter(ROW_TYPE);
+        assertInvalidValue(
+                () -> converter.convert(row(6, new BigDecimal("1.5"), 
BigDecimal.ONE, null, null)),
+                "`big`",
+                "DECIMAL(39, 0)",
+                "1.5");
+        BigDecimal fortyDigits = new BigDecimal("1" + repeat('0', 39));
+        assertInvalidValue(
+                () -> converter.convert(row(7, fortyDigits, BigDecimal.ONE, 
null, null)),
+                "`big`",
+                "DECIMAL(39, 0)",
+                "more than 39 digits");
+        SeaTunnelRowConverter genericConverter = new 
SeaTunnelRowConverter(ROW_TYPE);
+        assertInvalidValue(
+                () ->
+                        genericConverter.convert(
+                                row(8, BigDecimal.ONE, BigDecimal.ONE, null, 
fortyDigits)),
+                "`info.inner_big`",
+                "DECIMAL(39, 0)",
+                "more than 39 digits");
+    }
+
+    @Test
+    void readRejectsMalformedCarrierText() {
+        for (String text :
+                new String[] {"", " 1", "1 ", "1e5", "1E+5", "NaN", "+1", 
"1,000", "0x1F", "-"}) {
+            assertInvalidValue(
+                    () -> WideDecimalUtils.fromCarrierText(text, DECIMAL_39_0, 
"big"),
+                    "`big`",
+                    "DECIMAL(39, 0)",
+                    "not plain decimal text");
+        }
+        assertInvalidValue(
+                () -> WideDecimalUtils.fromCarrierText("1.5", DECIMAL_39_0, 
"big"),
+                "`big`",
+                "DECIMAL(39, 0)",
+                "scale is 1, expected 0");
+        assertInvalidValue(
+                () -> WideDecimalUtils.fromCarrierText("1.00", DECIMAL_50_10, 
"scaled"),
+                "`scaled`",
+                "DECIMAL(50, 10)",
+                "scale is 2, expected 10");
+        assertInvalidValue(
+                () -> WideDecimalUtils.fromCarrierText("1" + repeat('0', 39), 
DECIMAL_39_0, "big"),
+                "`big`",
+                "DECIMAL(39, 0)",
+                "more than 39 digits");
+    }
+
+    @Test
+    void readErrorNamesNestedColumnAndTruncatesValue() {
+        String longText = "x" + repeat('1', 100);
+        GenericRow info = new GenericRow(new Object[] {longText, "n"});
+        GenericRow row = new GenericRow(new Object[] {(byte) 0, "db.t", 1, 
null, null, null, info});
+        SeaTunnelRuntimeException e =
+                assertInvalidValue(
+                        () -> new 
SeaTunnelRowConverter(ROW_TYPE).reconvert(row),
+                        "`info.inner_big`",
+                        "DECIMAL(39, 0)",
+                        "not plain decimal text");
+        Assertions.assertTrue(e.getMessage().contains(longText.substring(0, 
64) + "..."));
+        Assertions.assertFalse(e.getMessage().contains(longText));
+    }
+
+    @Test
+    void internalRowReadRejectsMalformedCarrierText() {
+        MutableValue[] values = new MutableValue[ROW_TYPE.getTotalFields() + 
2];
+        for (int i = 0; i < values.length; i++) {
+            values[i] = new MutableAny();
+        }
+        values[0] = new MutableByte();
+        values[0].update((byte) 0);
+        values[1].update(UTF8String.fromString("db.t"));
+        values[3].update(UTF8String.fromString("12.0"));
+        assertInvalidValue(
+                () -> new InternalRowConverter(ROW_TYPE).reconvert(new 
SpecificInternalRow(values)),
+                "`big`",
+                "DECIMAL(39, 0)",
+                "scale is 1, expected 0");
+    }
+
+    @Test
+    void ordinaryDecimalWithUnexpectedValueFailsInsteadOfFallingThrough() {
+        MutableValue[] values = new MutableValue[ROW_TYPE.getTotalFields() + 
2];
+        for (int i = 0; i < values.length; i++) {
+            values[i] = new MutableAny();
+        }
+        values[0] = new MutableByte();
+        values[0].update((byte) 0);
+        values[1].update(UTF8String.fromString("db.t"));
+        values[5].update("not a decimal");
+        IllegalArgumentException e =
+                Assertions.assertThrows(
+                        IllegalArgumentException.class,
+                        () ->
+                                new InternalRowConverter(ROW_TYPE)
+                                        .reconvert(new 
SpecificInternalRow(values)));
+        Assertions.assertTrue(e.getMessage().contains("java.lang.String"), 
e.getMessage());
+    }
+
+    @Test
+    void preflightRejectsWideDecimalInArray() {
+        assertCollectionError(arrayOf(DECIMAL_39_0), "demo.t_wide", 
"tags.element");
+    }
+
+    @Test
+    void preflightRejectsWideDecimalMapValue() {
+        assertCollectionError(
+                new MapType<>(BasicType.STRING_TYPE, DECIMAL_39_0), 
"demo.t_wide", "col.value");
+    }
+
+    @Test
+    void preflightRejectsWideDecimalMapKey() {
+        assertCollectionError(
+                new MapType<>(DECIMAL_39_0, BasicType.STRING_TYPE), 
"demo.t_wide", "col.key");
+    }
+
+    @Test
+    void preflightRejectsWideDecimalInArrayInsideRow() {
+        SeaTunnelRowType info =
+                new SeaTunnelRowType(
+                        new String[] {"scores"},
+                        new SeaTunnelDataType<?>[] {arrayOf(DECIMAL_39_0)});
+        assertCollectionError(info, "demo.t_wide", "col.scores.element");
+    }
+
+    @Test
+    void preflightAllowsNarrowDecimalInCollections() {
+        SeaTunnelRowType rowType =
+                new SeaTunnelRowType(
+                        new String[] {"tags", "attrs"},
+                        new SeaTunnelDataType<?>[] {
+                            arrayOf(new DecimalType(38, 0)),
+                            new MapType<>(BasicType.STRING_TYPE, new 
DecimalType(38, 10))
+                        });
+        Assertions.assertDoesNotThrow(
+                () -> new MultiTableManager(new CatalogTable[] 
{table("demo.ok", rowType)}));
+    }
+
+    @Test
+    void preflightNamesOriginalTableAndColumnInMultiTableJob() {
+        SeaTunnelRowType good =
+                new SeaTunnelRowType(
+                        new String[] {"id", "big"},
+                        new SeaTunnelDataType<?>[] {BasicType.INT_TYPE, 
DECIMAL_39_0});
+        SeaTunnelRowType bad =
+                new SeaTunnelRowType(
+                        new String[] {"id", "largeint_values"},
+                        new SeaTunnelDataType<?>[] {BasicType.INT_TYPE, 
arrayOf(DECIMAL_39_0)});
+        SeaTunnelRuntimeException e =
+                Assertions.assertThrows(
+                        SeaTunnelRuntimeException.class,
+                        () ->
+                                new MultiTableManager(
+                                        new CatalogTable[] {
+                                            table("demo.a_good", good), 
table("demo.b_bad", bad)
+                                        }));
+        Assertions.assertEquals(
+                SparkTranslationErrorCode.WIDE_DECIMAL_IN_COLLECTION, 
e.getSeaTunnelErrorCode());
+        Assertions.assertTrue(e.getMessage().contains("demo.b_bad"), 
e.getMessage());
+        
Assertions.assertTrue(e.getMessage().contains("`largeint_values.element`"), 
e.getMessage());
+        Assertions.assertFalse(e.getMessage().contains("column0"), 
e.getMessage());
+        Assertions.assertFalse(e.getMessage().contains("column1"), 
e.getMessage());
+    }
+
+    @Test
+    void multiTableJobWithTopLevelWideDecimalStillRoundTrips() throws 
IOException {
+        SeaTunnelRowType a =
+                new SeaTunnelRowType(
+                        new String[] {"id", "big"},
+                        new SeaTunnelDataType<?>[] {BasicType.INT_TYPE, 
DECIMAL_39_0});
+        SeaTunnelRowType b =
+                new SeaTunnelRowType(
+                        new String[] {"scaled"}, new SeaTunnelDataType<?>[] 
{DECIMAL_50_10});
+        MultiTableManager manager =
+                new MultiTableManager(new CatalogTable[] {table("demo.a", a), 
table("demo.b", b)});
+        StructType schema = manager.getTableSchema();
+        assertCarrier(schema.apply("column1"), 39, 0);
+        assertCarrier(schema.apply("column2"), 50, 10);
+
+        SeaTunnelRow rowA = new SeaTunnelRow(new Object[] {1, INT128_MIN});
+        rowA.setTableId("demo.a");
+        SeaTunnelRow rowB = new SeaTunnelRow(new Object[] {SCALED});
+        rowB.setTableId("demo.b");
+        for (SeaTunnelRow row : Arrays.asList(rowA, rowB)) {
+            GenericRow genericRow = manager.convert(row);
+            assertRowEquals(row, manager.reconvert(genericRow));
+        }
+    }
+
+    @Test
+    void collectionConversionOutsidePreflightStillFails() {
+        SeaTunnelRuntimeException e =
+                Assertions.assertThrows(
+                        SeaTunnelRuntimeException.class,
+                        () -> 
TypeConverterUtils.convert(arrayOf(DECIMAL_39_0)));
+        Assertions.assertTrue(e.getMessage().contains("`element`"), 
e.getMessage());
+        SeaTunnelRowType rowInArray =
+                new SeaTunnelRowType(new String[] {"v"}, new 
SeaTunnelDataType<?>[] {DECIMAL_39_0});
+        e =
+                Assertions.assertThrows(
+                        SeaTunnelRuntimeException.class,
+                        () ->
+                                TypeConverterUtils.convert(
+                                        new MapType<>(
+                                                BasicType.STRING_TYPE,
+                                                new ArrayType<>(
+                                                        SeaTunnelRow[].class, 
rowInArray))));
+        Assertions.assertEquals(
+                SparkTranslationErrorCode.WIDE_DECIMAL_IN_COLLECTION, 
e.getSeaTunnelErrorCode());
+    }
+
+    @Test
+    void valueErrorNamesFullPathOfDeeplyNestedColumn() {
+        SeaTunnelRowType outerType =
+                new SeaTunnelRowType(
+                        new String[] {"id", "outer"},
+                        new SeaTunnelDataType<?>[] {
+                            BasicType.INT_TYPE,
+                            new SeaTunnelRowType(
+                                    new String[] {"info"}, new 
SeaTunnelDataType<?>[] {INFO_TYPE})
+                        });
+        BigDecimal fortyDigits = new BigDecimal("1" + repeat('0', 39));
+        SeaTunnelRow info = new SeaTunnelRow(new Object[] {fortyDigits, "n"});
+        SeaTunnelRow row =
+                new SeaTunnelRow(new Object[] {1, new SeaTunnelRow(new 
Object[] {info})});
+        row.setTableId("db.t");
+        assertInvalidValue(
+                () -> new InternalRowConverter(outerType).convert(row),
+                "`outer.info.inner_big`",
+                "more than 39 digits");
+        assertInvalidValue(
+                () -> new SeaTunnelRowConverter(outerType).convert(row),
+                "`outer.info.inner_big`",
+                "more than 39 digits");
+    }
+
+    @Test
+    void metadataReadBackRejectsInconsistentFields() {
+        Metadata onlyPrecision =
+                new MetadataBuilder().putLong(WideDecimalUtils.PRECISION_KEY, 
39).build();
+        Metadata both = WideDecimalUtils.metadata(DECIMAL_39_0);
+        Metadata narrow =
+                new MetadataBuilder()
+                        .putLong(WideDecimalUtils.PRECISION_KEY, 20)
+                        .putLong(WideDecimalUtils.SCALE_KEY, 0)
+                        .build();
+        Metadata wrongType =
+                new MetadataBuilder()
+                        .putString(WideDecimalUtils.PRECISION_KEY, "39")
+                        .putLong(WideDecimalUtils.SCALE_KEY, 0)
+                        .build();
+        for (StructField field :
+                new StructField[] {
+                    new StructField("a", DataTypes.StringType, true, 
onlyPrecision),
+                    new StructField("b", DataTypes.LongType, true, both),
+                    new StructField("c", DataTypes.StringType, true, narrow),
+                    new StructField("d", DataTypes.StringType, true, wrongType)
+                }) {
+            SeaTunnelRuntimeException e =
+                    Assertions.assertThrows(
+                            SeaTunnelRuntimeException.class,
+                            () ->
+                                    TypeConverterUtils.convert(
+                                            new StructType(new StructField[] 
{field})));
+            Assertions.assertEquals(
+                    SparkTranslationErrorCode.INVALID_WIDE_DECIMAL_METADATA,
+                    e.getSeaTunnelErrorCode());
+            Assertions.assertTrue(e.getMessage().contains("`" + field.name() + 
"`"));
+        }
+    }
+
+    private static void assertCollectionError(
+            SeaTunnelDataType<?> columnType, String tablePath, String 
columnPath) {
+        String columnName = columnPath.substring(0, columnPath.indexOf('.'));
+        SeaTunnelRowType rowType =
+                new SeaTunnelRowType(
+                        new String[] {"id", columnName},
+                        new SeaTunnelDataType<?>[] {BasicType.INT_TYPE, 
columnType});
+        SeaTunnelRuntimeException e =
+                Assertions.assertThrows(
+                        SeaTunnelRuntimeException.class,
+                        () ->
+                                new MultiTableManager(
+                                        new CatalogTable[] {table(tablePath, 
rowType)}));
+        Assertions.assertEquals(
+                SparkTranslationErrorCode.WIDE_DECIMAL_IN_COLLECTION, 
e.getSeaTunnelErrorCode());
+        String message = e.getMessage();
+        for (String expected :
+                new String[] {
+                    "`" + columnPath + "`",
+                    tablePath,
+                    "DECIMAL(39, 0)",
+                    "Spark supports DECIMAL precision up to 38",
+                    "doris.read.field",
+                    "source query",
+                    "Zeta or Flink"
+                }) {
+            Assertions.assertTrue(message.contains(expected), expected + " not 
in: " + message);
+        }
+    }
+
+    private static SeaTunnelRuntimeException assertInvalidValue(
+            Executable executable, String... expectedParts) {
+        SeaTunnelRuntimeException e =
+                Assertions.assertThrows(SeaTunnelRuntimeException.class, 
executable);
+        Assertions.assertEquals(
+                SparkTranslationErrorCode.INVALID_WIDE_DECIMAL_VALUE, 
e.getSeaTunnelErrorCode());
+        for (String expected : expectedParts) {
+            Assertions.assertTrue(
+                    e.getMessage().contains(expected), expected + " not in: " 
+ e.getMessage());
+        }
+        return e;
+    }
+
+    private static void assertCarrier(StructField field, long precision, long 
scale) {
+        Assertions.assertEquals(DataTypes.StringType, field.dataType(), 
field.name());
+        Assertions.assertEquals(
+                precision, 
field.metadata().getLong(WideDecimalUtils.PRECISION_KEY), field.name());
+        Assertions.assertEquals(
+                scale, field.metadata().getLong(WideDecimalUtils.SCALE_KEY), 
field.name());
+    }
+
+    private static void assertRowEquals(SeaTunnelRow expected, SeaTunnelRow 
actual) {
+        Assertions.assertEquals(expected.getArity(), actual.getArity());
+        for (int i = 0; i < expected.getArity(); i++) {
+            Object e = expected.getField(i);
+            Object a = actual.getField(i);
+            if (e instanceof SeaTunnelRow) {
+                assertRowEquals((SeaTunnelRow) e, (SeaTunnelRow) a);
+            } else {
+                // BigDecimal.equals also compares the scale.
+                Assertions.assertEquals(e, a, "field " + i);
+            }
+        }
+    }
+
+    private static ArrayType<BigDecimal[], BigDecimal> arrayOf(DecimalType 
type) {
+        return new ArrayType<>(BigDecimal[].class, type);
+    }
+
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    private static ExpressionEncoder<Row> rowEncoder(StructType schema) {
+        // Spark 3.3 API; the Spark 2.4 and 3.3 end-to-end tests cover both 
versions.
+        scala.collection.Seq attributes = (scala.collection.Seq) 
schema.toAttributes();
+        return RowEncoder.apply(schema).resolveAndBind(attributes, 
SimpleAnalyzer$.MODULE$);
+    }
+
+    private static CatalogTable table(String path, SeaTunnelRowType rowType) {
+        String[] parts = path.split("\\.");
+        return CatalogTableUtil.getCatalogTable("test", parts[0], null, 
parts[1], rowType);
+    }
+
+    private static String repeat(char c, int count) {
+        char[] chars = new char[count];
+        Arrays.fill(chars, c);
+        return new String(chars);
+    }
+}

Reply via email to