Attached is a comment that captures a lot of the complexities of case
mapping. It's a bit verbose, but it seems worthwhile to get this
information recorded somewhere. Includes examples and tests to cover
those examples.
Regards,
Jeff Davis
From d8546716261bf3c58cd111670f860e26ddd76b8f Mon Sep 17 00:00:00 2001
From: Jeff Davis <[email protected]>
Date: Mon, 24 Aug 2026 11:29:46 -0700
Subject: [PATCH v1] Add "Case Mapping Complexities" comment and test.
There are a number of important edge cases and complexities that some
callers need to consider, e.g. string expansion. Also add tests to
cover the examples in the comment.
---
src/backend/utils/adt/pg_locale.c | 68 ++++++++++++++++++++++
src/test/regress/expected/collate.utf8.out | 8 ++-
src/test/regress/sql/collate.utf8.sql | 4 +-
3 files changed, 77 insertions(+), 3 deletions(-)
diff --git a/src/backend/utils/adt/pg_locale.c b/src/backend/utils/adt/pg_locale.c
index 7f73cd75956..3b9c9524315 100644
--- a/src/backend/utils/adt/pg_locale.c
+++ b/src/backend/utils/adt/pg_locale.c
@@ -1317,6 +1317,66 @@ strupper_c(char *dst, size_t dstsize, const char *src, size_t srclen)
return srclen;
}
+/*
+ * Case Mapping Complexities
+ *
+ * Below are general notes on the complexities of Unicode case mapping (the C
+ * locale uses simple ASCII semantics). See the Unicode Standard and the
+ * provider implementation for details.
+ *
+ * Case mapping can depend on the provider and locale, but in practice there
+ * are three things that matter:
+ *
+ * 1. On what version of Unicode is the provider based?
+ * 2. Is the locale based on "simple" (libc locales & C.UTF-8) or "full" (ICU
+ * locales & PG_UNICODE_FAST) case mappings?
+ * 3. Is the language one of "tr" or "az" (which have special rules for "i")?
+ *
+ * There are very few differences in case mapping among different languages
+ * and regions. One important difference, though: if the language is "az" or
+ * "tr", the uppercase of "i" is U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE,
+ * and the lowercase/foldcase of "I" is U+0131 LATIN SMALL LETTER DOTLESS I.
+ *
+ * Case mapping is for both Case Conversion (lowercasing, titlecasing, and
+ * uppercasing) as well as Case Folding. Case Conversion is for display;
+ * whereas Case Folding is to create a canonical caseless form of the string
+ * for case-insensitive matching.
+ *
+ * For "full" case mapping, some characters map to more than one other
+ * character, so the result may be longer than the original. Unicode defines
+ * the maximum string expansion to be 3x the code points (not to be confused
+ * with bytes); see Unicode 17.0 section 5.18.2. Examples: U+0130 LATIN
+ * CAPITAL LETTER I WITH DOT ABOVE lowercases to "i" followed by U+0307
+ * COMBINING DOT ABOVE; U+FB01 LATIN SMALL LIGATURE FI titlecases to "Fi";
+ * U+0390 GREEK SMALL LETTER IOTA WITH DIALYTIKA AND TONOS uppercases to <0399
+ * 0308 0301>; U+00DF LATIN SMALL LETTER SHARP S casefolds to "ss". NB: even
+ * for "simple" case mapping, the byte length can change because the mapped
+ * characters may have a different encoded length.
+ *
+ * Some characters have more than two forms. For instance, Greek Sigma has
+ * forms U+03A3 GREEK CAPITAL LETTER SIGMA, U+03C3 GREEK SMALL LETTER SIGMA,
+ * and U+03C2 GREEK SMALL LETTER FINAL SIGMA.
+ *
+ * When converting case, the mapping chosen may depend on the context within
+ * the string; i.e. it's not always a pure mapping. For instance, lowercasing
+ * U+03A3 results in U+03C2 if it's at the end of a word; otherwise U+03C3.
+ * Titlecasing uses uppercase (or titlecase, if available) mappings for the
+ * initial letter of a word; otherwise it uses the lowercase mapping.
+ * Casefolding is a pure mapping and never depends on context.
+ *
+ * Normalization is useful after casefolding to improve the quality of
+ * caseless matching. For a single edge case, U+0345 COMBINING GREEK
+ * YPOGEGRAMMENI, casefolding maps to a character with a different combining
+ * class, and it's useful to normalize both before and after casefolding.
+ *
+ * Mappings may depend on the provider and the version of Unicode on which it
+ * is based. Unassigned code points map to themselves, so their mapping may
+ * change in a later version of Unicode that assigns those codepoints. If
+ * mapping only assigned code points, the results of casefolding are
+ * guaranteed to be stable across Unicode versions (case conversion has weaker
+ * guarantees but still quite stable for assigned code points).
+ */
+
/*
* pg_strlower()
*
@@ -1326,6 +1386,8 @@ strupper_c(char *dst, size_t dstsize, const char *src, size_t srclen)
* Lowercasing is intended for human-readable display. If the goal is to
* convert to a canonical caseless form, see pg_strfold().
*
+ * See Case Mapping Complexities comment above.
+ *
* src must be in the database encoding with no embedded NULs. If dstsize is
* zero, dst may be NULL, which is useful for calculating the required buffer
* size before allocating.
@@ -1355,6 +1417,8 @@ pg_strlower(char *dst, size_t dstsize, const char *src, size_t srclen,
* titlecase form, if available), and all other characters lowercased. Used
* to implement the SQL INITCAP() function.
*
+ * See Case Mapping Complexities comment above.
+ *
* src must be in the database encoding with no embedded NULs. If dstsize is
* zero, dst may be NULL, which is useful for calculating the required buffer
* size before allocating.
@@ -1382,6 +1446,8 @@ pg_strtitle(char *dst, size_t dstsize, const char *src, size_t srclen,
* Uppercasing is intended for human-readable display. If the goal is to
* convert to a canonical caseless form, see pg_strfold().
*
+ * See Case Mapping Complexities comment above.
+ *
* src must be in the database encoding with no embedded NULs. If dstsize is
* zero, dst may be NULL, which is useful for calculating the required buffer
* size before allocating.
@@ -1415,6 +1481,8 @@ pg_strupper(char *dst, size_t dstsize, const char *src, size_t srclen,
* display. Unicode guarantees that casefolding is stable across versions if
* the original string consists only of assigned code points.
*
+ * See Case Mapping Complexities comment above.
+ *
* src must be in the database encoding with no embedded NULs. If dstsize is
* zero, dst may be NULL, which is useful for calculating the required buffer
* size before allocating.
diff --git a/src/test/regress/expected/collate.utf8.out b/src/test/regress/expected/collate.utf8.out
index cdd1a37ba18..3b8d6392732 100644
--- a/src/test/regress/expected/collate.utf8.out
+++ b/src/test/regress/expected/collate.utf8.out
@@ -194,7 +194,9 @@ INSERT INTO test_pg_unicode_fast VALUES
(U&'Λλ 1a \FF11a'),
('ȺȺȺ'),
('ⱥⱥⱥ'),
- ('ⱥȺ');
+ ('ⱥȺ'),
+ (U&'\FB01'),
+ (U&'\0390');
SELECT
t, lower(t), initcap(t), upper(t),
length(convert_to(t, 'UTF8')) AS t_bytes,
@@ -211,7 +213,9 @@ SELECT
ȺȺȺ | ⱥⱥⱥ | Ⱥⱥⱥ | ȺȺȺ | 6 | 9 | 8 | 6
ⱥⱥⱥ | ⱥⱥⱥ | Ⱥⱥⱥ | ȺȺȺ | 9 | 9 | 8 | 6
ⱥȺ | ⱥⱥ | Ⱥⱥ | ȺȺ | 5 | 6 | 5 | 4
-(7 rows)
+ fi | fi | Fi | FI | 3 | 3 | 2 | 2
+ ΐ | ΐ | Ϊ́ | Ϊ́ | 2 | 2 | 6 | 6
+(9 rows)
DROP TABLE test_pg_unicode_fast;
-- test Final_Sigma
diff --git a/src/test/regress/sql/collate.utf8.sql b/src/test/regress/sql/collate.utf8.sql
index 52cf068dd0c..6875f6f9e35 100644
--- a/src/test/regress/sql/collate.utf8.sql
+++ b/src/test/regress/sql/collate.utf8.sql
@@ -107,7 +107,9 @@ INSERT INTO test_pg_unicode_fast VALUES
(U&'Λλ 1a \FF11a'),
('ȺȺȺ'),
('ⱥⱥⱥ'),
- ('ⱥȺ');
+ ('ⱥȺ'),
+ (U&'\FB01'),
+ (U&'\0390');
SELECT
t, lower(t), initcap(t), upper(t),
--
2.43.0