From f5ca9ffe4f35ea0b8880426ce6ffa61e265cbd6f Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <andrew@dunslane.net>
Date: Tue, 6 Oct 2026 17:10:09 -0400
Subject: [PATCH v5] Add ASCII fast path to Unicode normalization functions

unicode_is_normalized(), unicode_assigned(), and unicode_normalize_func()
each decoded its whole input to an array of char32_t codepoints, one
utf8_to_unicode()/pg_utf_mblen() call at a time, before doing any real
work.  ASCII characters need none of that: they have no canonical or
compatibility decomposition, a combining class of zero, and are all
assigned.  So leading ASCII in a string is unchanged by normalization in
any form, except that its last character might compose with a following
combining mark.

Find the leading ASCII with a byte scan using the existing SIMD-vectorized
is_valid_ascii() (already used by pg_utf8_verifystr()).  If the whole
string is ASCII, return at once.  Otherwise decode and process only the
rest of the string, starting with the last ASCII character so that
composition with a following combining mark still happens.

unicode_assigned() now walks the rest of the string directly instead of
counting its codepoints with pg_mbstrlen_with_len() first.  Also, match
the normalization form argument against the text value instead of
converting it to a C string on every call; that allocation is a
noticeable part of the cost once the ASCII fast path is taken.

Add regression coverage for the all-ASCII case in all three functions,
including stored short-header and compressed values, sweeps over ASCII
prefixes of varying length, and invalid and mixed-case form names.

Author: Andrew Dunstan <andrew@dunslane.net>
Author: Nazir Bilal Yavuz <byavuz81@gmail.com>
Author: Rui Zhao <zhaorui126@gmail.com>
Reviewed-by: Tristan Partin <tristan@partin.io>
Reviewed-by: Chao Li <li.evan.chao@gmail.com>
Discussion: https://postgr.es/m/8cb366d6-a30f-49a4-b5d3-a5dfc0f2fea2@dunslane.net
---
 src/backend/utils/adt/varlena.c       | 125 +++++++++++++++++++++-----
 src/test/regress/expected/unicode.out | 112 +++++++++++++++++++++++
 src/test/regress/sql/unicode.sql      |  38 ++++++++
 3 files changed, 252 insertions(+), 23 deletions(-)

diff --git a/src/backend/utils/adt/varlena.c b/src/backend/utils/adt/varlena.c
index 6c95fcb24b7..2ab05488c78 100644
--- a/src/backend/utils/adt/varlena.c
+++ b/src/backend/utils/adt/varlena.c
@@ -35,6 +35,7 @@
 #include "parser/scansup.h"
 #include "port/pg_bswap.h"
 #include "regex/regex.h"
+#include "utils/ascii.h"
 #include "utils/builtins.h"
 #include "utils/guc.h"
 #include "utils/lsyscache.h"
@@ -167,6 +168,8 @@ static void text_format_string_conversion(StringInfo buf, char conversion,
 										  int flags, int width);
 static void text_format_append_string(StringInfo buf, const char *str,
 									  int flags, int width);
+static UnicodeNormalizationForm unicode_norm_form_from_text(text *formtxt);
+static int	valid_ascii_prefix_len(const unsigned char *s, int len);
 
 
 /*****************************************************************************
@@ -5419,8 +5422,10 @@ getClosestMatch(ClosestMatchState *state)
  */
 
 static UnicodeNormalizationForm
-unicode_norm_form_from_string(const char *formstr)
+unicode_norm_form_from_text(text *formtxt)
 {
+	const char *formstr = VARDATA_ANY(formtxt);
+	int			formlen = VARSIZE_ANY_EXHDR(formtxt);
 	UnicodeNormalizationForm form = -1;
 
 	/*
@@ -5431,18 +5436,18 @@ unicode_norm_form_from_string(const char *formstr)
 				(errcode(ERRCODE_SYNTAX_ERROR),
 				 errmsg("Unicode normalization can only be performed if server encoding is UTF8")));
 
-	if (pg_strcasecmp(formstr, "NFC") == 0)
+	if (formlen == 3 && pg_strncasecmp(formstr, "NFC", 3) == 0)
 		form = UNICODE_NFC;
-	else if (pg_strcasecmp(formstr, "NFD") == 0)
+	else if (formlen == 3 && pg_strncasecmp(formstr, "NFD", 3) == 0)
 		form = UNICODE_NFD;
-	else if (pg_strcasecmp(formstr, "NFKC") == 0)
+	else if (formlen == 4 && pg_strncasecmp(formstr, "NFKC", 4) == 0)
 		form = UNICODE_NFKC;
-	else if (pg_strcasecmp(formstr, "NFKD") == 0)
+	else if (formlen == 4 && pg_strncasecmp(formstr, "NFKD", 4) == 0)
 		form = UNICODE_NFKD;
 	else
 		ereport(ERROR,
 				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-				 errmsg("invalid normalization form: %s", formstr)));
+				 errmsg("invalid normalization form: %s", text_to_cstring(formtxt))));
 
 	return form;
 }
@@ -5475,6 +5480,33 @@ icu_unicode_version(PG_FUNCTION_ARGS)
 		PG_RETURN_NULL();
 }
 
+/*
+ * Return the length of an initial portion of s[0..len) that is valid ASCII,
+ * that is, contains no zero bytes and no bytes with the high bit set, or
+ * len if all of it is.
+ *
+ * is_valid_ascii() requires a length that is a multiple of sizeof(Vector8),
+ * so check one chunk at a time with it and then the remainder byte by byte.
+ * A failure inside a chunk is reported at the start of that chunk, so the
+ * result can be up to sizeof(Vector8) - 1 bytes short of the full valid
+ * ASCII prefix.
+ */
+static int
+valid_ascii_prefix_len(const unsigned char *s, int len)
+{
+	int			chunk_len = len - (len % sizeof(Vector8));
+
+	for (int i = 0; i < chunk_len; i += sizeof(Vector8))
+		if (!is_valid_ascii(s + i, sizeof(Vector8)))
+			return i;
+
+	for (int i = chunk_len; i < len; i++)
+		if (s[i] == 0 || IS_HIGHBIT_SET(s[i]))
+			return i;
+
+	return len;
+}
+
 /*
  * Check whether the string contains only assigned Unicode code
  * points. Requires that the database encoding is UTF-8.
@@ -5484,16 +5516,27 @@ unicode_assigned(PG_FUNCTION_ARGS)
 {
 	text	   *input = PG_GETARG_TEXT_PP(0);
 	unsigned char *p;
-	int			size;
+	unsigned char *end;
+	int			len;
+	int			start;
 
 	if (GetDatabaseEncoding() != PG_UTF8)
 		ereport(ERROR,
 				(errmsg("Unicode categorization can only be performed if server encoding is UTF8")));
 
-	/* convert to char32_t */
-	size = pg_mbstrlen_with_len(VARDATA_ANY(input), VARSIZE_ANY_EXHDR(input));
-	p = (unsigned char *) VARDATA_ANY(input);
-	for (int i = 0; i < size; i++)
+	/* ASCII code points are always assigned, so only check the rest */
+	len = VARSIZE_ANY_EXHDR(input);
+	start = valid_ascii_prefix_len((unsigned char *) VARDATA_ANY(input), len);
+	if (start == len)
+		PG_RETURN_BOOL(true);
+
+	/*
+	 * Check the remaining code points without first counting them, stopping
+	 * at a NUL as pg_mbstrlen_with_len() would.
+	 */
+	p = (unsigned char *) VARDATA_ANY(input) + start;
+	end = (unsigned char *) VARDATA_ANY(input) + len;
+	while (p < end && *p)
 	{
 		char32_t	uchar = utf8_to_unicode(p);
 		int			category = unicode_category(uchar);
@@ -5511,7 +5554,7 @@ Datum
 unicode_normalize_func(PG_FUNCTION_ARGS)
 {
 	text	   *input = PG_GETARG_TEXT_PP(0);
-	char	   *formstr = text_to_cstring(PG_GETARG_TEXT_PP(1));
+	text	   *formtxt = PG_GETARG_TEXT_PP(1);
 	UnicodeNormalizationForm form;
 	size_t		size;
 	char32_t   *input_chars;
@@ -5519,26 +5562,44 @@ unicode_normalize_func(PG_FUNCTION_ARGS)
 	unsigned char *p;
 	text	   *result;
 	size_t		i;
+	int			len;
+	int			start;
 
-	form = unicode_norm_form_from_string(formstr);
+	form = unicode_norm_form_from_text(formtxt);
+
+	/*
+	 * ASCII characters have no decomposition and a combining class of zero,
+	 * so leading ASCII is unchanged by normalization in any form, except that
+	 * its last character might compose with a following combining mark.  No
+	 * canonical composition has an ASCII character as its second element, so
+	 * nothing before that last character can be affected.  So return an
+	 * all-ASCII string as is, and otherwise normalize only the rest of the
+	 * string, starting with that last ASCII character.
+	 */
+	len = VARSIZE_ANY_EXHDR(input);
+	start = valid_ascii_prefix_len((unsigned char *) VARDATA_ANY(input), len);
+	if (start == len)
+		PG_RETURN_TEXT_P(input);
+	if (start > 0)
+		start--;
 
 	/* convert to char32_t */
-	size = pg_mbstrlen_with_len(VARDATA_ANY(input), VARSIZE_ANY_EXHDR(input));
+	size = pg_mbstrlen_with_len(VARDATA_ANY(input) + start, len - start);
 	input_chars = palloc_array(char32_t, size + 1);
-	p = (unsigned char *) VARDATA_ANY(input);
+	p = (unsigned char *) VARDATA_ANY(input) + start;
 	for (i = 0; i < size; i++)
 	{
 		input_chars[i] = utf8_to_unicode(p);
 		p += pg_utf_mblen(p);
 	}
 	input_chars[i] = (char32_t) '\0';
-	Assert((char *) p == VARDATA_ANY(input) + VARSIZE_ANY_EXHDR(input));
+	Assert((char *) p == VARDATA_ANY(input) + len);
 
 	/* action */
 	output_chars = unicode_normalize(form, input_chars);
 
-	/* convert back to UTF-8 string */
-	size = 0;
+	/* convert back to UTF-8 string, after the unchanged ASCII */
+	size = start;
 	for (char32_t *wp = output_chars; *wp; wp++)
 	{
 		unsigned char buf[4];
@@ -5551,6 +5612,8 @@ unicode_normalize_func(PG_FUNCTION_ARGS)
 	SET_VARSIZE(result, size + VARHDRSZ);
 
 	p = (unsigned char *) VARDATA_ANY(result);
+	memcpy(p, VARDATA_ANY(input), start);
+	p += start;
 	for (char32_t *wp = output_chars; *wp; wp++)
 	{
 		unicode_to_utf8(*wp, p);
@@ -5577,7 +5640,7 @@ Datum
 unicode_is_normalized(PG_FUNCTION_ARGS)
 {
 	text	   *input = PG_GETARG_TEXT_PP(0);
-	char	   *formstr = text_to_cstring(PG_GETARG_TEXT_PP(1));
+	text	   *formtxt = PG_GETARG_TEXT_PP(1);
 	UnicodeNormalizationForm form;
 	size_t		size;
 	char32_t   *input_chars;
@@ -5587,20 +5650,36 @@ unicode_is_normalized(PG_FUNCTION_ARGS)
 	UnicodeNormalizationQC quickcheck;
 	size_t		output_size;
 	bool		result;
+	int			len;
+	int			start;
 
-	form = unicode_norm_form_from_string(formstr);
+	form = unicode_norm_form_from_text(formtxt);
+
+	/*
+	 * Leading ASCII is unchanged by normalization, except that its last
+	 * character might compose with a following combining mark (see
+	 * unicode_normalize_func()).  So an all-ASCII string is normalized in
+	 * every form, and otherwise only the rest of the string, starting with
+	 * that last ASCII character, needs to be checked.
+	 */
+	len = VARSIZE_ANY_EXHDR(input);
+	start = valid_ascii_prefix_len((unsigned char *) VARDATA_ANY(input), len);
+	if (start == len)
+		PG_RETURN_BOOL(true);
+	if (start > 0)
+		start--;
 
 	/* convert to char32_t */
-	size = pg_mbstrlen_with_len(VARDATA_ANY(input), VARSIZE_ANY_EXHDR(input));
+	size = pg_mbstrlen_with_len(VARDATA_ANY(input) + start, len - start);
 	input_chars = palloc_array(char32_t, size + 1);
-	p = (unsigned char *) VARDATA_ANY(input);
+	p = (unsigned char *) VARDATA_ANY(input) + start;
 	for (i = 0; i < size; i++)
 	{
 		input_chars[i] = utf8_to_unicode(p);
 		p += pg_utf_mblen(p);
 	}
 	input_chars[i] = (char32_t) '\0';
-	Assert((char *) p == VARDATA_ANY(input) + VARSIZE_ANY_EXHDR(input));
+	Assert((char *) p == VARDATA_ANY(input) + len);
 
 	/* quick check (see UAX #15) */
 	quickcheck = unicode_is_normalized_quickcheck(form, input_chars);
diff --git a/src/test/regress/expected/unicode.out b/src/test/regress/expected/unicode.out
index 63e48d3a961..496ee4e7ae1 100644
--- a/src/test/regress/expected/unicode.out
+++ b/src/test/regress/expected/unicode.out
@@ -26,6 +26,12 @@ SELECT unicode_assigned(U&'abc\+10FFFF');
  f
 (1 row)
 
+SELECT unicode_assigned('abc');
+ unicode_assigned 
+------------------
+ t
+(1 row)
+
 SELECT normalize('');
  normalize 
 -----------
@@ -68,8 +74,35 @@ SELECT normalize(U&'\00E4\24D1c', NFKD) = U&'\0061\0308bc' COLLATE "C" AS test_n
  t
 (1 row)
 
+SELECT normalize('abc') = 'abc' COLLATE "C" AS test_ascii_idem;
+ test_ascii_idem 
+-----------------
+ t
+(1 row)
+
+-- Use stored values so this check is not folded to a constant.
+CREATE TABLE unicode_ascii_input (val text);
+INSERT INTO unicode_ascii_input VALUES
+    (''), ('abc'), (repeat('a', 64)), (repeat('b', 5000));
+SELECT bool_and(normalize(val) = val COLLATE "C") AS test_ascii_idem_column,
+    count(pg_column_compression(val)) AS num_compressed
+FROM unicode_ascii_input;
+ test_ascii_idem_column | num_compressed 
+------------------------+----------------
+ t                      |              1
+(1 row)
+
+DROP TABLE unicode_ascii_input;
 SELECT "normalize"('abc', 'def');  -- run-time error
 ERROR:  invalid normalization form: def
+SELECT "normalize"('abc', 'NFCx');  -- run-time error
+ERROR:  invalid normalization form: NFCx
+SELECT "normalize"(U&'\0061\0308', 'nFc') = U&'\00E4' COLLATE "C" AS test_form_case;
+ test_form_case 
+----------------
+ t
+(1 row)
+
 SELECT U&'\00E4\24D1c' IS NORMALIZED AS test_default;
  test_default 
 --------------
@@ -82,6 +115,12 @@ SELECT U&'\00E4\24D1c' IS NFC NORMALIZED AS test_nfc;
  t
 (1 row)
 
+SELECT 'abc' IS NORMALIZED AS test_ascii;
+ test_ascii 
+------------
+ t
+(1 row)
+
 SELECT num, val,
     val IS NFC NORMALIZED AS NFC,
     val IS NFD NORMALIZED AS NFD,
@@ -105,6 +144,79 @@ ORDER BY num;
 
 SELECT is_normalized('abc', 'def');  -- run-time error
 ERROR:  invalid normalization form: def
+SELECT is_normalized('abc', 'NFKCx');  -- run-time error
+ERROR:  invalid normalization form: NFKCx
+-- Exercise the ASCII fast-path's chunk/remainder boundary handling: a
+-- non-NFC-normalized codepoint sequence must still be detected as such
+-- regardless of how much pure-ASCII padding surrounds it.
+SELECT len,
+    (repeat('a', len) || U&'\0061\0308') IS NORMALIZED AS non_nfc_at_end,
+    (U&'\0061\0308' || repeat('a', len)) IS NORMALIZED AS non_nfc_at_start
+FROM generate_series(0, 40) AS len
+ORDER BY len;
+ len | non_nfc_at_end | non_nfc_at_start 
+-----+----------------+------------------
+   0 | f              | f
+   1 | f              | f
+   2 | f              | f
+   3 | f              | f
+   4 | f              | f
+   5 | f              | f
+   6 | f              | f
+   7 | f              | f
+   8 | f              | f
+   9 | f              | f
+  10 | f              | f
+  11 | f              | f
+  12 | f              | f
+  13 | f              | f
+  14 | f              | f
+  15 | f              | f
+  16 | f              | f
+  17 | f              | f
+  18 | f              | f
+  19 | f              | f
+  20 | f              | f
+  21 | f              | f
+  22 | f              | f
+  23 | f              | f
+  24 | f              | f
+  25 | f              | f
+  26 | f              | f
+  27 | f              | f
+  28 | f              | f
+  29 | f              | f
+  30 | f              | f
+  31 | f              | f
+  32 | f              | f
+  33 | f              | f
+  34 | f              | f
+  35 | f              | f
+  36 | f              | f
+  37 | f              | f
+  38 | f              | f
+  39 | f              | f
+  40 | f              | f
+(41 rows)
+
+-- normalize() and unicode_assigned() skip leading ASCII too, so check that
+-- they give the right answer whatever its length.  A combining mark right
+-- after the ASCII must still compose with its last character.  This lists
+-- only the lengths that give a wrong answer.
+SELECT len
+FROM generate_series(0, 40) AS len
+WHERE normalize(repeat('a', len) || U&'\0061\0308', NFC)
+        <> (repeat('a', len) || U&'\00E4') COLLATE "C"
+   OR normalize(repeat('a', len) || U&'\00E4' || repeat('b', len), NFD)
+        <> (repeat('a', len) || U&'\0061\0308' || repeat('b', len)) COLLATE "C"
+   OR normalize(repeat('a', len) || U&'\00E4' || repeat('b', len), NFC)
+        <> (repeat('a', len) || U&'\00E4' || repeat('b', len)) COLLATE "C"
+   OR unicode_assigned(repeat('a', len) || U&'\+10FFFF')
+   OR NOT unicode_assigned(repeat('a', len) || U&'\00E4' || repeat('b', len));
+ len 
+-----
+(0 rows)
+
 -- Hangul NFC recomposition tests
 -- L+V -> LV composition (first and last)
 SELECT normalize(U&'\1100\1161', NFC) = U&'\AC00' COLLATE "C" AS hangul_lv_first;
diff --git a/src/test/regress/sql/unicode.sql b/src/test/regress/sql/unicode.sql
index 951f86a336e..b21353d44f7 100644
--- a/src/test/regress/sql/unicode.sql
+++ b/src/test/regress/sql/unicode.sql
@@ -8,6 +8,7 @@ SELECT U&'\0061\0308bc' <> U&'\00E4bc' COLLATE "C" AS sanity_check;
 SELECT unicode_version() IS NOT NULL;
 SELECT unicode_assigned(U&'abc');
 SELECT unicode_assigned(U&'abc\+10FFFF');
+SELECT unicode_assigned('abc');
 
 SELECT normalize('');
 SELECT normalize(U&'\0061\0308\24D1c') = U&'\00E4\24D1c' COLLATE "C" AS test_default;
@@ -16,11 +17,23 @@ SELECT normalize(U&'\00E4bc', NFC) = U&'\00E4bc' COLLATE "C" AS test_nfc_idem;
 SELECT normalize(U&'\00E4\24D1c', NFD) = U&'\0061\0308\24D1c' COLLATE "C" AS test_nfd;
 SELECT normalize(U&'\0061\0308\24D1c', NFKC) = U&'\00E4bc' COLLATE "C" AS test_nfkc;
 SELECT normalize(U&'\00E4\24D1c', NFKD) = U&'\0061\0308bc' COLLATE "C" AS test_nfkd;
+SELECT normalize('abc') = 'abc' COLLATE "C" AS test_ascii_idem;
+-- Use stored values so this check is not folded to a constant.
+CREATE TABLE unicode_ascii_input (val text);
+INSERT INTO unicode_ascii_input VALUES
+    (''), ('abc'), (repeat('a', 64)), (repeat('b', 5000));
+SELECT bool_and(normalize(val) = val COLLATE "C") AS test_ascii_idem_column,
+    count(pg_column_compression(val)) AS num_compressed
+FROM unicode_ascii_input;
+DROP TABLE unicode_ascii_input;
 
 SELECT "normalize"('abc', 'def');  -- run-time error
+SELECT "normalize"('abc', 'NFCx');  -- run-time error
+SELECT "normalize"(U&'\0061\0308', 'nFc') = U&'\00E4' COLLATE "C" AS test_form_case;
 
 SELECT U&'\00E4\24D1c' IS NORMALIZED AS test_default;
 SELECT U&'\00E4\24D1c' IS NFC NORMALIZED AS test_nfc;
+SELECT 'abc' IS NORMALIZED AS test_ascii;
 
 SELECT num, val,
     val IS NFC NORMALIZED AS NFC,
@@ -36,6 +49,31 @@ FROM
 ORDER BY num;
 
 SELECT is_normalized('abc', 'def');  -- run-time error
+SELECT is_normalized('abc', 'NFKCx');  -- run-time error
+
+-- Exercise the ASCII fast-path's chunk/remainder boundary handling: a
+-- non-NFC-normalized codepoint sequence must still be detected as such
+-- regardless of how much pure-ASCII padding surrounds it.
+SELECT len,
+    (repeat('a', len) || U&'\0061\0308') IS NORMALIZED AS non_nfc_at_end,
+    (U&'\0061\0308' || repeat('a', len)) IS NORMALIZED AS non_nfc_at_start
+FROM generate_series(0, 40) AS len
+ORDER BY len;
+
+-- normalize() and unicode_assigned() skip leading ASCII too, so check that
+-- they give the right answer whatever its length.  A combining mark right
+-- after the ASCII must still compose with its last character.  This lists
+-- only the lengths that give a wrong answer.
+SELECT len
+FROM generate_series(0, 40) AS len
+WHERE normalize(repeat('a', len) || U&'\0061\0308', NFC)
+        <> (repeat('a', len) || U&'\00E4') COLLATE "C"
+   OR normalize(repeat('a', len) || U&'\00E4' || repeat('b', len), NFD)
+        <> (repeat('a', len) || U&'\0061\0308' || repeat('b', len)) COLLATE "C"
+   OR normalize(repeat('a', len) || U&'\00E4' || repeat('b', len), NFC)
+        <> (repeat('a', len) || U&'\00E4' || repeat('b', len)) COLLATE "C"
+   OR unicode_assigned(repeat('a', len) || U&'\+10FFFF')
+   OR NOT unicode_assigned(repeat('a', len) || U&'\00E4' || repeat('b', len));
 
 -- Hangul NFC recomposition tests
 -- L+V -> LV composition (first and last)
-- 
2.43.0

