Skip to content

Commit 285b6bf

Browse files
[SPARK-58275][SQL][PYTHON] Add normalize Unicode normalization SQL function
1 parent 3d59361 commit 285b6bf

20 files changed

Lines changed: 412 additions & 2 deletions

File tree

common/utils/src/main/resources/error/error-conditions.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4648,6 +4648,11 @@
46484648
"expects a long literal, but got <invalidValue>."
46494649
]
46504650
},
4651+
"NORMALIZE_FORM" : {
4652+
"message" : [
4653+
"expects one of the normalization forms 'NFC', 'NFD', 'NFKC', 'NFKD', but got <form>."
4654+
]
4655+
},
46514656
"NULL" : {
46524657
"message" : [
46534658
"expects a non-NULL value."

python/docs/source/reference/pyspark.sql/functions.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ String Functions
191191
ltrim
192192
make_valid_utf8
193193
mask
194+
normalize
194195
octet_length
195196
overlay
196197
position

python/pyspark/sql/connect/functions/builtin.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2665,6 +2665,16 @@ def try_validate_utf8(str: "ColumnOrName") -> Column:
26652665
try_validate_utf8.__doc__ = pysparkfuncs.try_validate_utf8.__doc__
26662666

26672667

2668+
def normalize(str: "ColumnOrName", form: Optional["ColumnOrName"] = None) -> Column:
2669+
if form is None:
2670+
return _invoke_function_over_columns("normalize", str)
2671+
else:
2672+
return _invoke_function_over_columns("normalize", str, form)
2673+
2674+
2675+
normalize.__doc__ = pysparkfuncs.normalize.__doc__
2676+
2677+
26682678
def format_number(col: "ColumnOrName", d: int) -> Column:
26692679
return _invoke_function("format_number", _to_col(col), lit(d))
26702680

python/pyspark/sql/functions/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@
152152
"ltrim",
153153
"make_valid_utf8",
154154
"mask",
155+
"normalize",
155156
"octet_length",
156157
"overlay",
157158
"position",

python/pyspark/sql/functions/builtin.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15757,6 +15757,43 @@ def try_validate_utf8(str: "ColumnOrName") -> Column:
1575715757
return _invoke_function_over_columns("try_validate_utf8", str)
1575815758

1575915759

15760+
@_try_remote_functions
15761+
def normalize(str: "ColumnOrName", form: Optional["ColumnOrName"] = None) -> Column:
15762+
"""
15763+
Returns the Unicode normalization of ``str`` using the given normalization ``form``.
15764+
15765+
.. versionadded:: 4.3.0
15766+
15767+
Parameters
15768+
----------
15769+
str : :class:`~pyspark.sql.Column` or column name
15770+
the input string to normalize.
15771+
form : :class:`~pyspark.sql.Column` or column name, optional
15772+
the normalization form, one of 'NFC', 'NFD', 'NFKC', 'NFKD' (case-insensitive).
15773+
If omitted, 'NFC' is used.
15774+
15775+
Returns
15776+
-------
15777+
:class:`~pyspark.sql.Column`
15778+
the normalized string.
15779+
15780+
Examples
15781+
--------
15782+
>>> import pyspark.sql.functions as sf
15783+
>>> df = spark.createDataFrame([("\ufb01",)], ["s"])
15784+
>>> df.select(sf.normalize(df.s, sf.lit("NFKC"))).show()
15785+
+------------------+
15786+
|normalize(s, NFKC)|
15787+
+------------------+
15788+
| fi|
15789+
+------------------+
15790+
"""
15791+
if form is None:
15792+
return _invoke_function_over_columns("normalize", str)
15793+
else:
15794+
return _invoke_function_over_columns("normalize", str, form)
15795+
15796+
1576015797
@_try_remote_functions
1576115798
def format_number(col: "ColumnOrName", d: int) -> Column:
1576215799
"""

python/pyspark/sql/tests/test_functions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,14 @@ def test_jaro_winkler_similarity_function(self):
10221022
null_result = df.select(F.jaro_winkler_similarity(df.l, F.lit(None))).first()[0]
10231023
self.assertIsNone(null_result)
10241024

1025+
def test_normalize_function(self):
1026+
df = self.spark.createDataFrame([("\ufb01",)], ["s"])
1027+
result = df.select(F.normalize(df.s, F.lit("NFKC"))).first()[0]
1028+
self.assertEqual(result, "fi")
1029+
# Null handling
1030+
null_result = df.select(F.normalize(F.lit(None), F.lit("NFC"))).first()[0]
1031+
self.assertIsNone(null_result)
1032+
10251033
def test_between_function(self):
10261034
df = self.spark.createDataFrame(
10271035
[Row(a=1, b=2, c=3), Row(a=2, b=1, c=3), Row(a=4, b=1, c=4)]

sql/api/src/main/scala/org/apache/spark/sql/functions.scala

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7407,6 +7407,32 @@ object functions {
74077407
def try_validate_utf8(str: Column): Column =
74087408
Column.fn("try_validate_utf8", str)
74097409

7410+
/**
7411+
* Returns the Unicode normalization of `str` using the given normalization `form`. Valid forms
7412+
* are 'NFC', 'NFD', 'NFKC', and 'NFKD'. The form name is case-insensitive.
7413+
*
7414+
* @param str
7415+
* the input string to normalize.
7416+
* @param form
7417+
* the normalization form: 'NFC', 'NFD', 'NFKC', or 'NFKD'.
7418+
* @group string_funcs
7419+
* @since 4.3.0
7420+
*/
7421+
def normalize(str: Column, form: Column): Column =
7422+
Column.fn("normalize", str, form)
7423+
7424+
/**
7425+
* Returns the Unicode normalization of `str` using the default form 'NFC'. To use a different
7426+
* form, call the two-argument overload.
7427+
*
7428+
* @param str
7429+
* the input string to normalize.
7430+
* @group string_funcs
7431+
* @since 4.3.0
7432+
*/
7433+
def normalize(str: Column): Column =
7434+
Column.fn("normalize", str)
7435+
74107436
/**
74117437
* Formats numeric column x to a format like '#,###,###.##', rounded to d decimal places with
74127438
* HALF_EVEN round mode, and returns the result as a string column.

sql/catalyst/src/main/java/org/apache/spark/sql/catalyst/expressions/ExpressionImplUtils.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.security.spec.AlgorithmParameterSpec;
2424
import java.text.BreakIterator;
2525
import java.text.DecimalFormat;
26+
import java.text.Normalizer;
2627
import java.util.ArrayList;
2728
import java.util.List;
2829
import java.util.Locale;
@@ -149,6 +150,24 @@ public static UTF8String tryValidateUTF8String(UTF8String utf8String) {
149150
else return null;
150151
}
151152

153+
/**
154+
* Normalizes the given string using the given Unicode normalization form.
155+
*
156+
* @param input the input string to normalize.
157+
* @param form the normalization form, one of NFC, NFD, NFKC, NFKD (case-insensitive).
158+
* @return the normalized string.
159+
*/
160+
public static UTF8String normalize(UTF8String input, UTF8String form) {
161+
Normalizer.Form normalizerForm = switch (form.toString().toUpperCase(Locale.ROOT)) {
162+
case "NFC" -> Normalizer.Form.NFC;
163+
case "NFD" -> Normalizer.Form.NFD;
164+
case "NFKC" -> Normalizer.Form.NFKC;
165+
case "NFKD" -> Normalizer.Form.NFKD;
166+
default -> throw QueryExecutionErrors.invalidNormalizeFormError(form.toString());
167+
};
168+
return UTF8String.fromString(Normalizer.normalize(input.toString(), normalizerForm));
169+
}
170+
152171
public static byte[] aesEncrypt(byte[] input,
153172
byte[] key,
154173
UTF8String mode,

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionRegistry.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,7 @@ object FunctionRegistry {
687687
expression[ValidateUTF8]("validate_utf8"),
688688
expression[TryValidateUTF8]("try_validate_utf8"),
689689
expression[Quote]("quote"),
690+
expression[Normalize]("normalize"),
690691

691692
// url functions
692693
expression[UrlEncode]("url_encode"),

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/stringExpressions.scala

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -961,6 +961,62 @@ case class TryValidateUTF8(input: Expression) extends RuntimeReplaceable with Im
961961

962962
}
963963

964+
/**
965+
* A function that returns the Unicode normalization of a string.
966+
*/
967+
// scalastyle:off
968+
@ExpressionDescription(
969+
usage = """
970+
_FUNC_(str[, form]) - Returns the Unicode normalization of `str` using the normalization `form`.
971+
Valid forms are 'NFC' (default), 'NFD', 'NFKC', and 'NFKD'. The form name is case-insensitive.
972+
""",
973+
arguments = """
974+
Arguments:
975+
* str - a string expression to normalize.
976+
* form - a string expression giving the normalization form: 'NFC', 'NFD', 'NFKC', or 'NFKD'.
977+
If omitted, 'NFC' is used.
978+
""",
979+
examples = """
980+
Examples:
981+
> SELECT _FUNC_('fi', 'NFKC');
982+
fi
983+
""",
984+
since = "4.3.0",
985+
group = "string_funcs")
986+
// scalastyle:on
987+
case class Normalize(input: Expression, form: Expression)
988+
extends RuntimeReplaceable with ImplicitCastInputTypes with BinaryLike[Expression] {
989+
override def nullIntolerant: Boolean = true
990+
991+
override lazy val replacement: Expression =
992+
StaticInvoke(
993+
classOf[ExpressionImplUtils],
994+
input.dataType,
995+
"normalize",
996+
Seq(input, form),
997+
inputTypes)
998+
999+
def this(input: Expression) = this(input, Literal("NFC"))
1000+
1001+
override def inputTypes: Seq[AbstractDataType] =
1002+
Seq(StringTypeWithCollation(supportsTrimCollation = true),
1003+
StringTypeWithCollation(supportsTrimCollation = true))
1004+
1005+
override def nodeName: String = "normalize"
1006+
1007+
override def nullable: Boolean = true
1008+
1009+
override def left: Expression = input
1010+
1011+
override def right: Expression = form
1012+
1013+
override protected def withNewChildrenInternal(
1014+
newLeft: Expression, newRight: Expression): Normalize = {
1015+
copy(input = newLeft, form = newRight)
1016+
}
1017+
1018+
}
1019+
9641020
/**
9651021
* Replace all occurrences with string.
9661022
*/

0 commit comments

Comments
 (0)