This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new c9aeac253b78 [SPARK-48973][SQL] Fix mask to handle characters outside
the BMP
c9aeac253b78 is described below
commit c9aeac253b78ee910134fbd9191944f6021e4ae1
Author: Eric Yang <[email protected]>
AuthorDate: Mon Aug 10 10:55:43 2026 +0800
[SPARK-48973][SQL] Fix mask to handle characters outside the BMP
### What changes were proposed in this pull request?
Make `Mask.transformInput` iterate by code point instead of by UTF-16 code
unit, and read the replacement character with `codePointAt(0)` instead of
`charAt(0)`. The character classification logic is unchanged; codegen needs no
change because `doGenCode` calls the same static method. Also hoists the
replacement lookup out of the per-character loop, which allocated a String per
character previously.
### Why are the changes needed?
`mask` is documented for "creating copies of tables with sensitive
information removed", but sees each supplementary character as two unpaired
surrogates:
```SQL
SELECT mask('A𝐀 1𝟎'); -- 'X𝐀 n𝟎', expected 'XX nn'
SELECT mask('🙂', 'Y', 'y', 'n', '*'); -- '**', expected '*'
SELECT mask('ABC', '🙂'); -- '???', expected '🙂🙂🙂'
```
Both halves classify as SURROGATE, so the first silently skips redaction:
supplementary letters and digits (U+1D400, or Adlam at U+1E900) are
UPPERCASE_LETTER / LOWERCASE_LETTER / DECIMAL_DIGIT_NUMBER and should be
masked, but take the otherChar branch and pass through. The second replaces one
input character twice. The third is internally inconsistent:
checkInputDataTypes accepts '🙂' as one character (UTF8String.numChars counts
code points), then evaluation truncates it.
The char-wise iteration came from Hive's GenericUDFMask, but Spark did not
port Hive's argument handling, and Spark is code-point based elsewhere (length,
substring, instr, locate, position) - with the same fix applied (SPARK-55453,
SPARK-57932, etc). Only the iteration changes here; classification still
matches Hive, so OTHER_LETTER scripts (CJK, Hebrew, ...) keep taking the
otherChar branch as today.
### Does this PR introduce _any_ user-facing change?
Yes. mask now treats a supplementary character as a single character. Input
consisting only of BMP characters is unaffected. Supplementary letters and
digits are now masked where they were previously passed through, the result
keeps the input's character count, and a supplementary replacement character is
used as given instead of becoming '?'.
### How was this patch tested?
New test cases added
### Was this patch authored or co-authored using generative AI tooling?
Yes.
Closes #57858 from jiwen624/SPARK-48973.
Authored-by: Eric Yang <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
---
.../sql/catalyst/expressions/maskExpressions.scala | 49 +++++++++++++++-------
.../expressions/StringExpressionsSuite.scala | 27 ++++++++++++
2 files changed, 61 insertions(+), 15 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/maskExpressions.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/maskExpressions.scala
index 9613bc25d65f..a283eb1c8143 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/maskExpressions.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/maskExpressions.scala
@@ -319,6 +319,7 @@ object Mask {
val MASKED_DIGIT = 'n'
// This value helps to retain original value in the input by ignoring the
replacement rules
val MASKED_IGNORE = null
+ private val IGNORE_CODE_POINT = -1
def transformInput(
input: Any,
@@ -330,29 +331,47 @@ object Mask {
val transformedString = if (input == null) {
null
} else {
- input.toString.map {
- transformChar(_, maskUpper, maskLower, maskDigit, maskOther).toChar
+ val upper = replacementCodePoint(maskUpper)
+ val lower = replacementCodePoint(maskLower)
+ val digit = replacementCodePoint(maskDigit)
+ val other = replacementCodePoint(maskOther)
+ val str = input.toString
+ val sb = new java.lang.StringBuilder(str.length)
+ var i = 0
+ while (i < str.length) {
+ val codePoint = str.codePointAt(i)
+ sb.appendCodePoint(transformCodePoint(codePoint, upper, lower, digit,
other))
+ i += Character.charCount(codePoint)
}
+ sb.toString
}
org.apache.spark.unsafe.types.UTF8String.fromString(transformedString)
}
- private def transformChar(
- c: Char,
- maskUpper: Any,
- maskLower: Any,
- maskDigit: Any,
- maskOther: Any): Int = {
+ private def replacementCodePoint(option: Any): Int = {
+ if (option != MASKED_IGNORE) {
+ option.asInstanceOf[UTF8String].toString.codePointAt(0)
+ } else {
+ IGNORE_CODE_POINT
+ }
+ }
+
+ private def transformCodePoint(
+ codePoint: Int,
+ maskUpper: Int,
+ maskLower: Int,
+ maskDigit: Int,
+ maskOther: Int): Int = {
- def maskedChar(c: Char, option: Any): Char = {
- if (option != MASKED_IGNORE)
option.asInstanceOf[UTF8String].toString.charAt(0) else c
+ def maskedCodePoint(replacement: Int): Int = {
+ if (replacement != IGNORE_CODE_POINT) replacement else codePoint
}
- Character.getType(c) match {
- case Character.UPPERCASE_LETTER => maskedChar(c, maskUpper)
- case Character.LOWERCASE_LETTER => maskedChar(c, maskLower)
- case Character.DECIMAL_DIGIT_NUMBER => maskedChar(c, maskDigit)
- case _ => maskedChar(c, maskOther)
+ Character.getType(codePoint) match {
+ case Character.UPPERCASE_LETTER => maskedCodePoint(maskUpper)
+ case Character.LOWERCASE_LETTER => maskedCodePoint(maskLower)
+ case Character.DECIMAL_DIGIT_NUMBER => maskedCodePoint(maskDigit)
+ case _ => maskedCodePoint(maskOther)
}
}
}
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/StringExpressionsSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/StringExpressionsSuite.scala
index d2a58b95b83e..4ae723f33b39 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/StringExpressionsSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/StringExpressionsSuite.scala
@@ -451,6 +451,33 @@ class StringExpressionsSuite extends SparkFunSuite with
ExpressionEvalHelper {
}
}
+ test("SPARK-48973: Mask with supplementary characters") {
+ def cp(codePoint: Int): String = new String(Character.toChars(codePoint))
+ val smile = cp(0x1F642)
+ val boldA = cp(0x1D400)
+ val boldSmallA = cp(0x1D41A)
+ val boldZero = cp(0x1D7CE)
+
+ checkEvaluation(
+ new Mask(Literal(smile), Literal('Y'), Literal('y'), Literal('n'),
Literal('*')), "*")
+ checkEvaluation(new Mask(Literal("ABC"), Literal(smile)), smile * 3)
+ checkEvaluation(new Mask(Literal(s"A$boldA 1$boldZero")), "XX nn")
+ // Supplementary upper-case, lower-case and digit characters are each
classified and
+ // replaced like their BMP counterparts.
+ checkEvaluation(new Mask(Literal(s"$boldA$boldSmallA$boldZero")), "Xxn")
+ // A supplementary replacement applied to a supplementary input.
+ checkEvaluation(new Mask(Literal(boldSmallA), Literal('Y'),
Literal(smile)), smile)
+
+ // A supplementary character must round-trip intact through the retain
path, both when it
+ // falls into the otherChar category and when its own category is set to
retain.
+ checkEvaluation(new Mask(Literal(smile)), smile)
+ checkEvaluation(new Mask(Literal(s"a${smile}1")), s"x${smile}n")
+ checkEvaluation(new Mask(Literal(boldA), Literal(null, StringType)), boldA)
+ checkEvaluation(
+ new Mask(Literal(boldZero), Literal('Y'), Literal('y'), Literal(null,
StringType)),
+ boldZero)
+ }
+
test("SPARK-42384: Mask with null input") {
val NULL_LITERAL = Literal(null, StringType)
checkEvaluation(
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]