You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@datafusion.apache.org by gi...@apache.org on 2026/10/11 16:58:31 UTC

(datafusion-comet) 01/01: fix: accept all BMP Unicode decimal digits in cast(string as decimal) (#6841)

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

github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-6841-d8d37fc527e9498dcab5c1a52a470edb8fa11718
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git

commit 04d66aecc24e239157ef2a7376e9d07ac996ca90
Author: Liang-Chi Hsieh <vi...@gmail.com>
AuthorDate: Sun Oct 11 16:57:45 2026 +0000

    fix: accept all BMP Unicode decimal digits in cast(string as decimal) (#6841)
    
    * fix: accept all BMP Unicode decimal digits in cast(string as decimal)
    
    Spark casts a string to decimal with `new java.math.BigDecimal(s.trim)`,
    which accepts any char for which `Character.isDigit(char)` is true, in
    both the mantissa and the exponent. Comet only normalized fullwidth
    digits, so strings such as '٣' (U+0663 ARABIC-INDIC DIGIT THREE) or
    '1e३' returned NULL, or raised CAST_INVALID_INPUT under ANSI, where Spark
    returns a value.
    
    Normalize every non-ASCII Nd digit in the Basic Multilingual Plane to
    ASCII before parsing. Supplementary-plane digits stay rejected, since
    BigDecimal sees them as surrogate pairs. The pure-ASCII fast path is
    unchanged. Integral, floating-point and date casts are left alone: Spark
    only accepts ASCII digits there.
    
    * perf: keep ASCII and fullwidth digits off the table lookup
    
    Normalizing every char through the Nd table slowed down already
    supported fullwidth and mixed ASCII/fullwidth input. Scan bytes instead,
    copying ASCII directly and mapping the fullwidth UTF-8 encoding in place
    as before, and only decode a char for the table lookup otherwise. The
    output buffer is preallocated, since it is never longer than the input.
    
    Add fullwidth, mixed ASCII/fullwidth and Arabic-Indic cases to the
    cast_from_string benchmark.
---
 .../expressions/_category_template/cast.md         |   7 +-
 native/spark-expr/benches/cast_from_string.rs      |  46 +++++
 native/spark-expr/src/conversion_funcs/string.rs   | 200 ++++++++++++++++++---
 .../cast/cast_string_to_decimal_unicode_digits.sql |  88 +++++++++
 .../cast_string_to_decimal_unicode_digits_ansi.sql |  76 ++++++++
 5 files changed, 393 insertions(+), 24 deletions(-)

diff --git a/docs/source/user-guide/latest/compatibility/expressions/_category_template/cast.md b/docs/source/user-guide/latest/compatibility/expressions/_category_template/cast.md
index c023539f36..f55199125c 100644
--- a/docs/source/user-guide/latest/compatibility/expressions/_category_template/cast.md
+++ b/docs/source/user-guide/latest/compatibility/expressions/_category_template/cast.md
@@ -64,8 +64,11 @@ including:
   `java.lang.String.trim` that Spark applies before handing the value to `BigDecimal`.
 - Null bytes (`\u0000`) at the start or end of a string are therefore trimmed. Null bytes
   embedded in the middle of a string produce `NULL`.
-- Fullwidth Unicode digits (U+FF10–U+FF19, e.g. `123.45`) are treated as their ASCII
-  equivalents, so `CAST('123.45' AS DECIMAL(10,2))` returns `123.45`.
+- Non-ASCII decimal digits that Java's `BigDecimal` accepts, i.e. every Unicode `Nd` digit in
+  the Basic Multilingual Plane (fullwidth `123.45`, Arabic-Indic `١٢٣`, Devanagari `१२३`,
+  ...), are treated as their ASCII equivalents in both the mantissa and the exponent, so
+  `CAST('١٢٣.٤٥' AS DECIMAL(10,2))` returns `123.45`. Digits outside the Basic Multilingual
+  Plane, such as U+1D7CE MATHEMATICAL BOLD DIGIT ZERO, are rejected as they are by Spark.
 - Scientific notation (e.g. `1.23E+5`) is supported.
 - Special values (`inf`, `infinity`, `nan`) produce `NULL`.
 
diff --git a/native/spark-expr/benches/cast_from_string.rs b/native/spark-expr/benches/cast_from_string.rs
index cd34467012..608fe75469 100644
--- a/native/spark-expr/benches/cast_from_string.rs
+++ b/native/spark-expr/benches/cast_from_string.rs
@@ -136,6 +136,29 @@ fn criterion_benchmark(c: &mut Criterion) {
         group.finish();
     }
 
+    // str -> decimal with non-ASCII digits, which take the normalization path in
+    // `parse_string_to_decimal` instead of the pure-ASCII fast path
+    let spark_cast_options = SparkCastOptions::new(EvalMode::Legacy, "", false);
+    let cast = Cast::new(
+        expr.clone(),
+        DataType::Decimal128(38, 10),
+        spark_cast_options,
+        None,
+        None,
+    );
+    let mut group = c.benchmark_group("cast_string_to_decimal_unicode/legacy");
+    for (name, zero, every_other) in [
+        ("fullwidth", '\u{FF10}', false),
+        ("mixed_ascii_fullwidth", '\u{FF10}', true),
+        ("arabic_indic", '\u{0660}', false),
+    ] {
+        let batch = create_unicode_decimal_cast_string_batch(zero, every_other);
+        group.bench_function(name, |b| {
+            b.iter(|| cast.evaluate(&batch).unwrap());
+        });
+    }
+    group.finish();
+
     // str -> boolean and str -> float benchmarks, with and without the leading/trailing
     // whitespace that exercises the trim helpers in `conversion_funcs::trim`
     let bool_batch = create_boolean_string_batch(false);
@@ -252,6 +275,29 @@ fn create_decimal_cast_string_batch() -> RecordBatch {
     })
 }
 
+/// Create batch of decimal strings whose digits are re-spelled in the script whose zero is
+/// `zero`, either all of them or every other one (mixing with ASCII)
+fn create_unicode_decimal_cast_string_batch(zero: char, every_other: bool) -> RecordBatch {
+    let mut rng = StdRng::seed_from_u64(42);
+    string_batch(8192, 10, move |_| {
+        let ascii = format!(
+            "-{}.{}",
+            rng.random_range(0..1_000_000_000u32),
+            rng.random_range(0..1_000_000_000u32)
+        );
+        ascii
+            .chars()
+            .enumerate()
+            .map(|(i, c)| match c.to_digit(10) {
+                Some(d) if !every_other || i % 2 == 0 => {
+                    char::from_u32(u32::from(zero) + d).unwrap()
+                }
+                _ => c,
+            })
+            .collect()
+    })
+}
+
 fn config() -> Criterion {
     Criterion::default()
 }
diff --git a/native/spark-expr/src/conversion_funcs/string.rs b/native/spark-expr/src/conversion_funcs/string.rs
index f278bafda6..89c18255d0 100644
--- a/native/spark-expr/src/conversion_funcs/string.rs
+++ b/native/spark-expr/src/conversion_funcs/string.rs
@@ -461,37 +461,110 @@ fn cast_string_to_decimal256_impl(
     ))
 }
 
-/// Normalize fullwidth Unicode digits (U+FF10–U+FF19) to their ASCII equivalents.
+/// Code point of the zero of each run of ten non-ASCII decimal digits (Unicode general
+/// category `Nd`) in the Basic Multilingual Plane, in ascending order. Every run is contiguous,
+/// so the digit at `zero + n` has value `n`.
 ///
-/// Spark's UTF8String parser treats fullwidth digits as numerically equivalent to
-/// ASCII digits, e.g. "123.45" parses as 123.45. Each fullwidth digit encodes
-/// to exactly three UTF-8 bytes: [0xEF, 0xBC, 0x90+n] for digit n. The ASCII
-/// equivalent is 0x30+n, so the conversion is: third_byte - 0x60.
+/// This is the set of `char`s for which Java's `Character.isDigit(char)` is true, minus ASCII.
+/// It is identical under JDK 17 (Unicode 13) and JDK 21 (Unicode 15); all `Nd` digits added
+/// since lie outside the BMP.
+const BMP_NON_ASCII_DIGIT_ZEROS: [u16; 36] = [
+    0x0660, // Arabic-Indic
+    0x06F0, // Extended Arabic-Indic
+    0x07C0, // NKo
+    0x0966, // Devanagari
+    0x09E6, // Bengali
+    0x0A66, // Gurmukhi
+    0x0AE6, // Gujarati
+    0x0B66, // Oriya
+    0x0BE6, // Tamil
+    0x0C66, // Telugu
+    0x0CE6, // Kannada
+    0x0D66, // Malayalam
+    0x0DE6, // Sinhala Lith
+    0x0E50, // Thai
+    0x0ED0, // Lao
+    0x0F20, // Tibetan
+    0x1040, // Myanmar
+    0x1090, // Myanmar Shan
+    0x17E0, // Khmer
+    0x1810, // Mongolian
+    0x1946, // Limbu
+    0x19D0, // New Tai Lue
+    0x1A80, // Tai Tham Hora
+    0x1A90, // Tai Tham Tham
+    0x1B50, // Balinese
+    0x1BB0, // Sundanese
+    0x1C40, // Lepcha
+    0x1C50, // Ol Chiki
+    0xA620, // Vai
+    0xA8D0, // Saurashtra
+    0xA900, // Kayah Li
+    0xA9D0, // Javanese
+    0xA9F0, // Myanmar Tai Laing
+    0xAA50, // Cham
+    0xABF0, // Meetei Mayek
+    0xFF10, // Fullwidth
+];
+
+/// The value of `c` if it is a non-ASCII decimal digit that Java's `BigDecimal` accepts.
 ///
-/// All other bytes (ASCII or other multi-byte sequences) are passed through
-/// unchanged, so the output is valid UTF-8 whenever the input is.
-fn normalize_fullwidth_digits(s: &str) -> String {
-    let bytes = s.as_bytes();
+/// `BigDecimal` scans UTF-16 code units and tests each with `Character.isDigit(char)`, so only
+/// BMP digits count: a supplementary digit such as U+1D7CE MATHEMATICAL BOLD DIGIT ZERO is a
+/// surrogate pair, neither half of which is a digit, and the parse fails.
+#[inline]
+fn non_ascii_digit_value(c: char) -> Option<u8> {
+    let cp = u32::from(c);
+    if cp > 0xFFFF {
+        return None;
+    }
+    let idx = BMP_NON_ASCII_DIGIT_ZEROS.partition_point(|&zero| u32::from(zero) <= cp);
+    let zero = u32::from(BMP_NON_ASCII_DIGIT_ZEROS[idx.checked_sub(1)?]);
+    let value = cp - zero;
+    (value < 10).then_some(value as u8)
+}
+
+/// Replace every non-ASCII decimal digit that Java's `BigDecimal` accepts with its ASCII
+/// equivalent, e.g. "١٢٣.٤٥" and "123.45" both become "123.45".
+///
+/// Spark parses decimals with `new java.math.BigDecimal(str)`, which reads each digit through
+/// `Character.digit(c, 10)` in both the mantissa and the exponent, so digits from any script
+/// can be mixed freely ("1٢e३" is 12000). All other characters are kept unchanged and are
+/// rejected later by [`parse_decimal_str`].
+fn normalize_unicode_digits(s: &str) -> String {
+    // Every non-ASCII digit is at least two UTF-8 bytes and becomes one, so the output is
+    // never longer than the input.
     let mut out = Vec::with_capacity(s.len());
+    let bytes = s.as_bytes();
     let mut i = 0;
     while i < bytes.len() {
-        if i + 2 < bytes.len()
-            && bytes[i] == 0xEF
-            && bytes[i + 1] == 0xBC
-            && bytes[i + 2] >= 0x90
-            && bytes[i + 2] <= 0x99
+        let b = bytes[i];
+        // ASCII (including mixed ASCII/fullwidth input) and fullwidth digits, encoded as
+        // [0xEF, 0xBC, 0x90 + n], are the common cases, so they skip the table lookup.
+        if b.is_ascii() {
+            out.push(b);
+            i += 1;
+        } else if b == 0xEF
+            && bytes.get(i + 1) == Some(&0xBC)
+            && matches!(bytes.get(i + 2), Some(0x90..=0x99))
         {
             // e.g. 0x91 - 0x60 = 0x31 = b'1'
             out.push(bytes[i + 2] - 0x60);
             i += 3;
         } else {
-            out.push(bytes[i]);
-            i += 1;
+            // `i` is on a char boundary: every branch above advances by a whole char.
+            let mut rest = s[i..].chars();
+            let c = rest.next().unwrap();
+            let next = bytes.len() - rest.as_str().len();
+            match non_ascii_digit_value(c) {
+                Some(d) => out.push(b'0' + d),
+                None => out.extend_from_slice(&bytes[i..next]),
+            }
+            i = next;
         }
     }
-    // SAFETY: we only replace valid 3-byte UTF-8 sequences [EF BC 9X] with a
-    // single ASCII byte; all other bytes are copied unchanged, preserving the
-    // UTF-8 invariant of the input.
+    // SAFETY: every branch either copies a whole UTF-8 encoded char unchanged or replaces it
+    // with a single ASCII byte, so the output is valid UTF-8 because the input is.
     unsafe { String::from_utf8_unchecked(out) }
 }
 
@@ -585,13 +658,13 @@ fn parse_string_to_decimal(input_str: &str, precision: u8, scale: i8) -> SparkRe
     // producing NULL.
     let trimmed = trim_java_string(input_str);
 
-    // Normalize fullwidth digits to ASCII. Fast path skips the allocation for
-    // pure-ASCII strings, which is the common case.
+    // Normalize non-ASCII digits (Arabic-Indic, Devanagari, fullwidth, ...) to ASCII. Fast path
+    // skips the allocation for pure-ASCII strings, which is the common case.
     let normalized;
     let trimmed = if trimmed.is_ascii() {
         trimmed
     } else {
-        normalized = normalize_fullwidth_digits(trimmed);
+        normalized = normalize_unicode_digits(trimmed);
         normalized.as_str()
     };
 
@@ -2335,6 +2408,89 @@ mod tests {
         assert!(parse_string_to_decimal(&over_long, 38, 10).is_err());
     }
 
+    #[test]
+    fn test_non_ascii_digit_value() {
+        for &zero in BMP_NON_ASCII_DIGIT_ZEROS.iter() {
+            for n in 0..10u8 {
+                let c = char::from_u32(u32::from(zero) + u32::from(n)).unwrap();
+                assert_eq!(non_ascii_digit_value(c), Some(n), "{c:?}");
+            }
+            // The code point just past each run is not a digit of that run.
+            let after = char::from_u32(u32::from(zero) + 10).unwrap();
+            assert_eq!(non_ascii_digit_value(after), None, "{after:?}");
+        }
+        for c in [
+            '0',
+            '9',
+            'a',
+            '.',
+            '\u{065F}', // just before Arabic-Indic zero
+            '\u{00B2}', // SUPERSCRIPT TWO (No)
+            '\u{2163}', // ROMAN NUMERAL FOUR (Nl)
+            '\u{2460}', // CIRCLED DIGIT ONE (No)
+            '\u{066B}', // ARABIC DECIMAL SEPARATOR
+            '\u{FFFF}',
+            '\u{1D7CE}', // MATHEMATICAL BOLD DIGIT ZERO: Nd, but outside the BMP
+            '\u{104A3}', // OSMANYA DIGIT THREE: Nd, but outside the BMP
+        ] {
+            assert_eq!(non_ascii_digit_value(c), None, "{c:?}");
+        }
+    }
+
+    #[test]
+    fn test_parse_string_to_decimal_unicode_digits() {
+        // Expected values come from `new java.math.BigDecimal(s)` on JDK 17, which is what
+        // Spark's Decimal.fromString / fromStringANSI call.
+        let ok = |s: &str, precision: u8, scale: i8| {
+            parse_string_to_decimal(s, precision, scale)
+                .unwrap()
+                .unwrap()
+        };
+        assert_eq!(ok("٣", 10, 2), 300); // Arabic-Indic
+        assert_eq!(ok("۳", 10, 2), 300); // Extended Arabic-Indic
+        assert_eq!(ok("३", 10, 2), 300); // Devanagari
+        assert_eq!(ok("৩", 10, 2), 300); // Bengali
+        assert_eq!(ok("๓", 10, 2), 300); // Thai
+        assert_eq!(ok("߃", 10, 2), 300); // NKo
+        assert_eq!(ok("꧓", 10, 2), 300); // Javanese
+        assert_eq!(ok("123.45", 10, 2), 12345); // Fullwidth
+        assert_eq!(ok("1٣", 10, 2), 1300);
+        assert_eq!(ok("-٣", 10, 2), -300);
+        assert_eq!(ok("+٣.٣٣", 10, 2), 333);
+        assert_eq!(ok(".٣", 10, 2), 30);
+        assert_eq!(ok("١٢٣.٤٥٥", 10, 2), 12346); // HALF_UP rounding
+        assert_eq!(ok("1e٣", 10, 2), 100000); // non-ASCII exponent digits
+        assert_eq!(ok("1E-٣", 10, 3), 1);
+        assert_eq!(ok("1e+٣", 10, 2), 100000);
+        assert_eq!(ok("1٢e३", 10, 0), 12000); // mixed scripts
+        assert_eq!(ok(" \t٣\n", 10, 2), 300); // ASCII trim still applies
+        assert_eq!(ok(&"٣".repeat(20), 38, 0), 33333333333333333333);
+
+        // Rejected by BigDecimal: not Nd, or Nd outside the BMP.
+        for s in [
+            "\u{1D7D0}", // MATHEMATICAL BOLD DIGIT TWO
+            "1\u{1D7D0}",
+            "1e\u{1D7D0}",
+            "\u{2163}",   // ROMAN NUMERAL FOUR
+            "\u{00B2}",   // SUPERSCRIPT TWO
+            "١\u{066B}٥", // ARABIC DECIMAL SEPARATOR is not '.'
+            "\u{00A0}٣",  // non-ASCII whitespace is not trimmed
+        ] {
+            // The error reports the original string, not the normalized one.
+            match parse_string_to_decimal(s, 10, 2) {
+                Err(SparkError::CastInvalidValue { value, .. }) => assert_eq!(value, s),
+                other => panic!("{s:?}: {other:?}"),
+            }
+        }
+
+        // Overflow is reported as such, not as an invalid input.
+        let err = parse_string_to_decimal("٣٣٣٣٣٣٣٣٣", 10, 2).unwrap_err();
+        assert!(
+            matches!(err, SparkError::NumericValueOutOfRange { .. }),
+            "{err}"
+        );
+    }
+
     #[test]
     #[cfg_attr(miri, ignore)] // test takes too long with miri
     fn test_cast_string_to_timestamp() {
diff --git a/spark/src/test/resources/sql-tests/expressions/cast/cast_string_to_decimal_unicode_digits.sql b/spark/src/test/resources/sql-tests/expressions/cast/cast_string_to_decimal_unicode_digits.sql
new file mode 100644
index 0000000000..f8d22a1d55
--- /dev/null
+++ b/spark/src/test/resources/sql-tests/expressions/cast/cast_string_to_decimal_unicode_digits.sql
@@ -0,0 +1,88 @@
+-- Licensed to the Apache Software Foundation (ASF) under one
+-- or more contributor license agreements.  See the NOTICE file
+-- distributed with this work for additional information
+-- regarding copyright ownership.  The ASF licenses this file
+-- to you under the Apache License, Version 2.0 (the
+-- "License"); you may not use this file except in compliance
+-- with the License.  You may obtain a copy of the License at
+--
+--   http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing,
+-- software distributed under the License is distributed on an
+-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+-- KIND, either express or implied.  See the License for the
+-- specific language governing permissions and limitations
+-- under the License.
+
+-- cast(string as decimal) with non-ASCII digits, non-ANSI mode. Spark parses the trimmed string
+-- with `new java.math.BigDecimal(s)`, which accepts every char for which
+-- `Character.isDigit(char)` is true: all Unicode `Nd` digits in the Basic Multilingual Plane,
+-- in the mantissa and in the exponent, mixed freely across scripts. Supplementary-plane digits
+-- (e.g. U+1D7D0 MATHEMATICAL BOLD DIGIT TWO) are surrogate pairs and are rejected.
+--
+-- The integral, floating-point and date casts of the same strings are ASCII-only in Spark
+-- (UTF8String.toLong, Double.parseDouble, SparkDateTimeUtils.stringToDate) and must stay NULL.
+
+-- Config: spark.sql.ansi.enabled=false
+
+statement
+CREATE TABLE test_cast_str_dec_unicode(id int, s string) USING parquet
+
+-- 1: U+0663 ARABIC-INDIC DIGIT THREE
+-- 2: U+06F3 EXTENDED ARABIC-INDIC DIGIT THREE
+-- 3: U+0969 DEVANAGARI DIGIT THREE
+-- 4: U+09E9 BENGALI DIGIT THREE
+-- 5: U+0E53 THAI DIGIT THREE
+-- 6: U+07C3 NKO DIGIT THREE
+-- 7: U+A9D3 JAVANESE DIGIT THREE
+-- 8: fullwidth digits
+-- 9-17: signs, '.', HALF_UP rounding, exponent digits, mixed scripts, ASCII trim
+-- 18: 20 Arabic-Indic digits, too many for the cast targets below
+-- 19-25: rejected by BigDecimal: supplementary-plane Nd digits, ROMAN NUMERAL FOUR (Nl),
+--        SUPERSCRIPT TWO (No), ARABIC DECIMAL SEPARATOR, leading NBSP (not trimmed)
+statement
+INSERT INTO test_cast_str_dec_unicode VALUES
+  (1, '٣'),
+  (2, '۳'),
+  (3, '३'),
+  (4, '৩'),
+  (5, '๓'),
+  (6, '߃'),
+  (7, '꧓'),
+  (8, '123.45'),
+  (9, '1٣'),
+  (10, '-٣'),
+  (11, '+٣.٣٣'),
+  (12, '.٣'),
+  (13, '١٢٣.٤٥٥'),
+  (14, '1e٣'),
+  (15, '1E-٣'),
+  (16, '1٢e३'),
+  (17, concat(' ٣', chr(9))),
+  (18, '٣٣٣٣٣٣٣٣٣٣٣٣٣٣٣٣٣٣٣٣'),
+  (19, '𝟐'),
+  (20, '1𝟐'),
+  (21, '1e𝟐'),
+  (22, 'Ⅳ'),
+  (23, '²'),
+  (24, '١٫٥'),
+  (25, concat(chr(160), '٣')),
+  (26, NULL)
+
+query
+SELECT id, cast(s as decimal(10,2)), cast(s as decimal(38,10)), cast(s as decimal(5,0))
+FROM test_cast_str_dec_unicode ORDER BY id
+
+query
+SELECT id, try_cast(s as decimal(10,2)), try_cast(s as decimal(38,0))
+FROM test_cast_str_dec_unicode ORDER BY id
+
+-- Spark's integral, floating-point and date parsers only accept ASCII digits
+query
+SELECT id, cast(s as int), cast(s as bigint), cast(s as double), cast(s as date)
+FROM test_cast_str_dec_unicode ORDER BY id
+
+-- literal arguments
+query
+SELECT cast('٣' as decimal(10,2)), cast('१२३.४५' as decimal(10,2)), cast('𝟐' as decimal(10,2))
diff --git a/spark/src/test/resources/sql-tests/expressions/cast/cast_string_to_decimal_unicode_digits_ansi.sql b/spark/src/test/resources/sql-tests/expressions/cast/cast_string_to_decimal_unicode_digits_ansi.sql
new file mode 100644
index 0000000000..f546893dfa
--- /dev/null
+++ b/spark/src/test/resources/sql-tests/expressions/cast/cast_string_to_decimal_unicode_digits_ansi.sql
@@ -0,0 +1,76 @@
+-- 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.
+
+-- cast(string as decimal) with non-ASCII digits, ANSI mode. Spark parses the trimmed string
+-- with `new java.math.BigDecimal(s)`, which accepts every Unicode `Nd` digit in the Basic
+-- Multilingual Plane, so these must cast to a value rather than raise CAST_INVALID_INPUT.
+-- Supplementary-plane digits are rejected by BigDecimal and must still raise.
+
+-- Config: spark.sql.ansi.enabled=true
+
+statement
+CREATE TABLE test_cast_str_dec_unicode_ansi(id int, s string) USING parquet
+
+-- Arabic-Indic, Devanagari, Thai, fullwidth, signs, '.', rounding, exponent digits, mixed
+-- scripts and ASCII trim; every value fits decimal(10,2)
+statement
+INSERT INTO test_cast_str_dec_unicode_ansi VALUES
+  (1, '٣'),
+  (2, '३'),
+  (3, '๓'),
+  (4, '123.45'),
+  (5, '-٣'),
+  (6, '+٣.٣٣'),
+  (7, '١٢٣.٤٥٥'),
+  (8, '1e٣'),
+  (9, '1E-٣'),
+  (10, '1٢e३'),
+  (11, concat(' ٣', chr(9))),
+  (12, NULL)
+
+statement
+CREATE TABLE test_cast_str_dec_unicode_ansi_bad(s string) USING parquet
+
+-- U+1D7D0 MATHEMATICAL BOLD DIGIT TWO is Nd but outside the BMP
+statement
+INSERT INTO test_cast_str_dec_unicode_ansi_bad VALUES ('𝟐')
+
+statement
+CREATE TABLE test_cast_str_dec_unicode_ansi_big(s string) USING parquet
+
+statement
+INSERT INTO test_cast_str_dec_unicode_ansi_big VALUES ('٣٣٣٣٣٣٣٣٣')
+
+query
+SELECT id, cast(s as decimal(10,2)), cast(s as decimal(38,10))
+FROM test_cast_str_dec_unicode_ansi ORDER BY id
+
+query
+SELECT id, try_cast(s as decimal(10,2)) FROM test_cast_str_dec_unicode_ansi ORDER BY id
+
+query expect_error(CAST_INVALID_INPUT)
+SELECT cast(s as decimal(10,2)) FROM test_cast_str_dec_unicode_ansi_bad
+
+-- the value is valid but does not fit decimal(10,2)
+query expect_error(NUMERIC_VALUE_OUT_OF_RANGE)
+SELECT cast(s as decimal(10,2)) FROM test_cast_str_dec_unicode_ansi_big
+
+-- try_cast suppresses both errors
+query
+SELECT try_cast(s as decimal(10,2)) FROM test_cast_str_dec_unicode_ansi_bad
+UNION ALL
+SELECT try_cast(s as decimal(10,2)) FROM test_cast_str_dec_unicode_ansi_big


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org