On Tue, 2026-08-18 at 14:36 -0400, Andres Freund wrote:
> FWIW, I think this is actually a bug, it just turns out that we don't
> know of
> any callers that hit it.
Committed.
Also committed the change to support collate_is_c always, for
consistency with ctype_is_c.
>
> > +/*
> > + * pg_strtitle()
> > + *
> > + * Convert src to titlecase, and return the result length (not
> > including
> > + * terminating NUL).
>
> Might not hurt to actually say what titlecase and folded strings are.
Attached follow-up patch that includes explanatory comments, and adds a
couple tiny SQL tests to cover the new examples.
>
> Sometimes that's unavoidable, because you need a server configured in
> a
> specific way, the tests take a good while and should therefore run
> concurrently, or such. But that shouldn't be the case her. Can't you
> stuff
> this into regress.c or such?
Attached new test patch that just adds it to regress.c and calls it
from misc_functions.sql.
> Other than that complaint, I'd probably backpatch this. Seems
> unlikely to be
> flappy or such?
Agreed. It would have caught 27e2afb492.
Thank you for looking at it.
Regards,
Jeff Davis
From 0b631dd835e54d5d2d7596a42cbd2bc38e9d87d8 Mon Sep 17 00:00:00 2001
From: Jeff Davis <[email protected]>
Date: Sat, 15 Aug 2026 12:26:09 -0700
Subject: [PATCH v3.pg20 1/2] Add C test function for pg_locale.h APIs.
Test the API independently to account for fallback paths that aren't
adequately tested from SQL.
The backport to 18 also tests the previously-supported behavior where
a size of -1 meant that the string was NUL-terminated. That behavior
was later removed in 19.
Reviewed-by: Andres Freund <[email protected]>
Discussion: https://postgr.es/m/v36ssaygf7grb3qzfsjhtdzi7kqd45ds56nyuf7gi5qjml4qbb@ezmfqzmhlrs2
Backpatch-through: 18
---
src/test/regress/expected/misc_functions.out | 40 ++++++
src/test/regress/regress.c | 134 +++++++++++++++++++
src/test/regress/sql/misc_functions.sql | 25 ++++
3 files changed, 199 insertions(+)
diff --git a/src/test/regress/expected/misc_functions.out b/src/test/regress/expected/misc_functions.out
index c3261bff209..2990e0c4f28 100644
--- a/src/test/regress/expected/misc_functions.out
+++ b/src/test/regress/expected/misc_functions.out
@@ -861,3 +861,43 @@ SELECT test_instr_time();
t
(1 row)
+--
+-- C tests for pg_locale.h APIs. No interesting output; tests will
+-- ERROR upon failure.
+--
+-- The test function is STRICT, so tests will be skipped if the
+-- collation is unavailable in the current database encoding
+-- (to_regcollation() will return NULL).
+--
+CREATE FUNCTION test_pg_locale_apis(oid)
+ RETURNS void
+ AS :'regresslib'
+ LANGUAGE C STRICT;
+-- Libc C. Available in every database.
+SELECT test_pg_locale_apis(to_regcollation('"C"'));
+ test_pg_locale_apis
+---------------------
+
+(1 row)
+
+-- Builtin C (collate and ctype). Usable only in UTF8 databases.
+SELECT test_pg_locale_apis(to_regcollation('ucs_basic'));
+ test_pg_locale_apis
+---------------------
+
+(1 row)
+
+-- Builtin C.UTF-8 (C collate, Unicode ctype). Same encoding restriction.
+SELECT test_pg_locale_apis(to_regcollation('pg_c_utf8'));
+ test_pg_locale_apis
+---------------------
+
+(1 row)
+
+-- en-x-icu is present when ICU collations were imported at initdb.
+SELECT test_pg_locale_apis(to_regcollation('en-x-icu'));
+ test_pg_locale_apis
+---------------------
+
+(1 row)
+
diff --git a/src/test/regress/regress.c b/src/test/regress/regress.c
index c2975ffc1b0..c72ee31cdce 100644
--- a/src/test/regress/regress.c
+++ b/src/test/regress/regress.c
@@ -48,6 +48,7 @@
#include "utils/builtins.h"
#include "utils/geo_decls.h"
#include "utils/memutils.h"
+#include "utils/pg_locale.h"
#include "utils/rel.h"
#include "utils/typcache.h"
@@ -1498,3 +1499,136 @@ test_pglz_decompress(PG_FUNCTION_ARGS)
SET_VARSIZE(result, dlen + VARHDRSZ);
PG_RETURN_BYTEA_P(result);
}
+
+static void
+test_case_mapping(pg_locale_t locale)
+{
+ char buf[32];
+ size_t n;
+
+ n = pg_strlower(NULL, 0, "AbC", 3, locale);
+ if (n != 3)
+ elog(ERROR, "pg_strlower() size probe returned %zu, expected 3", n);
+ n = pg_strlower(buf, 4, "AbC", 3, locale);
+ if (n != 3 || strcmp(buf, "abc") != 0)
+ elog(ERROR, "pg_strlower() produced \"%s\"", buf);
+
+ n = pg_strupper(NULL, 0, "AbC", 3, locale);
+ if (n != 3)
+ elog(ERROR, "pg_strupper() size probe returned %zu, expected 3", n);
+ n = pg_strupper(buf, 4, "AbC", 3, locale);
+ if (n != 3 || strcmp(buf, "ABC") != 0)
+ elog(ERROR, "pg_strupper() produced \"%s\"", buf);
+
+ n = pg_strfold(buf, 4, "AbC", 3, locale);
+ if (n != 3 || strcmp(buf, "abc") != 0)
+ elog(ERROR, "pg_strfold() produced \"%s\"", buf);
+
+ buf[0] = '\0';
+ n = pg_strtitle(buf, sizeof(buf), "hello-world", 11, locale);
+ if (n != 11)
+ elog(ERROR, "pg_strtitle() returned %zu, expected 11", n);
+ if (locale->ctype_is_c && strcmp(buf, "Hello-World") != 0)
+ elog(ERROR, "pg_strtitle() produced \"%s\"", buf);
+}
+
+static void
+test_collate(pg_locale_t locale)
+{
+ char buf[32];
+ char pfx[8];
+ char x1[8];
+ char x2[8];
+ size_t n;
+
+ if (pg_strcoll("abc", "abc", locale) != 0 ||
+ pg_strncoll("abc", 3, "abc", 3, locale) != 0 ||
+ pg_strcoll("", "", locale) != 0)
+ elog(ERROR, "equal strings did not compare equal");
+
+ if (locale->collate_is_c)
+ {
+ if (locale->collate != NULL)
+ elog(ERROR, "collate_is_c but collate methods are set");
+ if (pg_strcoll("abc", "abd", locale) >= 0 ||
+ pg_strcoll("abd", "abc", locale) <= 0 ||
+ pg_strncoll("ab", 2, "abc", 3, locale) >= 0 ||
+ pg_strncoll("abc", 3, "ab", 2, locale) <= 0 ||
+ pg_strncoll("xyz", 3, "abc", 2, locale) <= 0)
+ elog(ERROR, "C-locale comparison result is wrong");
+
+ if (!pg_strxfrm_enabled(locale))
+ elog(ERROR, "pg_strxfrm_enabled() is false for C locale");
+ n = pg_strnxfrm(NULL, 0, "abc", 3, locale);
+ if (n != 3)
+ elog(ERROR, "pg_strnxfrm() size probe returned %zu, expected 3", n);
+ n = pg_strnxfrm(buf, 4, "abc", 3, locale);
+ if (n != 3 || strcmp(buf, "abc") != 0)
+ elog(ERROR, "pg_strnxfrm() produced \"%s\"", buf);
+ n = pg_strxfrm(buf, "abc", 4, locale);
+ if (n != 3 || strcmp(buf, "abc") != 0)
+ elog(ERROR, "pg_strxfrm() produced \"%s\"", buf);
+ n = pg_strnxfrm(buf, 3, "abc", 3, locale);
+ if (n != 3)
+ elog(ERROR, "pg_strnxfrm() destsize==srclen returned %zu", n);
+ n = pg_strnxfrm(buf, 2, "abc", 3, locale);
+ if (n != 3)
+ elog(ERROR, "pg_strnxfrm() short dest returned %zu", n);
+
+ if (!pg_strxfrm_prefix_enabled(locale))
+ elog(ERROR, "pg_strxfrm_prefix_enabled() is false for C locale");
+ n = pg_strnxfrm_prefix(NULL, 0, "abcdef", 6, locale);
+ if (n != 0)
+ elog(ERROR, "pg_strnxfrm_prefix() destsize 0 returned %zu", n);
+ n = pg_strnxfrm_prefix(pfx, 2, "abcdef", 6, locale);
+ if (n != 2 || memcmp(pfx, "ab", 2) != 0)
+ elog(ERROR, "pg_strnxfrm_prefix() produced a wrong prefix");
+ n = pg_strnxfrm_prefix(pfx, sizeof(pfx), "abc", 3, locale);
+ if (n != 3 || memcmp(pfx, "abc", 3) != 0)
+ elog(ERROR, "pg_strnxfrm_prefix() destsize>=srclen produced a wrong result");
+ n = pg_strxfrm_prefix(pfx, "abcdef", 2, locale);
+ if (n != 2 || memcmp(pfx, "ab", 2) != 0)
+ elog(ERROR, "pg_strxfrm_prefix() produced a wrong prefix");
+
+ if (pg_strxfrm(x1, "abc", sizeof(x1), locale) >= sizeof(x1) ||
+ pg_strxfrm(x2, "abd", sizeof(x2), locale) >= sizeof(x2) ||
+ (strcmp(x1, x2) < 0) != (pg_strcoll("abc", "abd", locale) < 0))
+ elog(ERROR, "pg_strxfrm() disagrees with pg_strcoll()");
+ }
+ else
+ {
+ char *tmp;
+
+ if (locale->collate == NULL)
+ elog(ERROR, "collate methods missing for non-C locale");
+
+ n = pg_strnxfrm(NULL, 0, "abc", 3, locale);
+ tmp = palloc(n + 1);
+ if (pg_strnxfrm(tmp, n + 1, "abc", 3, locale) > n)
+ elog(ERROR, "pg_strnxfrm() grew on the second call");
+ pfree(tmp);
+
+ if (pg_strxfrm_prefix_enabled(locale) &&
+ pg_strnxfrm_prefix(pfx, sizeof(pfx), "abc", 3, locale) > sizeof(pfx))
+ elog(ERROR, "pg_strnxfrm_prefix() exceeded destsize");
+ }
+}
+
+/*
+ * Test pg_locale.h APIs directly, to cover cases not easily reachable by SQL.
+ */
+PG_FUNCTION_INFO_V1(test_pg_locale_apis);
+Datum
+test_pg_locale_apis(PG_FUNCTION_ARGS)
+{
+ pg_locale_t locale;
+
+ locale = pg_newlocale_from_collation(PG_GETARG_OID(0));
+ if (locale == NULL)
+ elog(ERROR, "pg_newlocale_from_collation() returned NULL");
+
+ test_collate(locale);
+ test_case_mapping(locale);
+
+ PG_RETURN_VOID();
+}
diff --git a/src/test/regress/sql/misc_functions.sql b/src/test/regress/sql/misc_functions.sql
index 946ee5726cd..950d9ab1a4a 100644
--- a/src/test/regress/sql/misc_functions.sql
+++ b/src/test/regress/sql/misc_functions.sql
@@ -356,3 +356,28 @@ CREATE FUNCTION test_instr_time()
AS :'regresslib'
LANGUAGE C;
SELECT test_instr_time();
+
+--
+-- C tests for pg_locale.h APIs. No interesting output; tests will
+-- ERROR upon failure.
+--
+-- The test function is STRICT, so tests will be skipped if the
+-- collation is unavailable in the current database encoding
+-- (to_regcollation() will return NULL).
+--
+CREATE FUNCTION test_pg_locale_apis(oid)
+ RETURNS void
+ AS :'regresslib'
+ LANGUAGE C STRICT;
+
+-- Libc C. Available in every database.
+SELECT test_pg_locale_apis(to_regcollation('"C"'));
+
+-- Builtin C (collate and ctype). Usable only in UTF8 databases.
+SELECT test_pg_locale_apis(to_regcollation('ucs_basic'));
+
+-- Builtin C.UTF-8 (C collate, Unicode ctype). Same encoding restriction.
+SELECT test_pg_locale_apis(to_regcollation('pg_c_utf8'));
+
+-- en-x-icu is present when ICU collations were imported at initdb.
+SELECT test_pg_locale_apis(to_regcollation('en-x-icu'));
--
2.43.0
From 83d7b5f26799bb27038036c01dbefaf72d6aee4b Mon Sep 17 00:00:00 2001
From: Jeff Davis <[email protected]>
Date: Tue, 18 Aug 2026 18:56:24 -0700
Subject: [PATCH v3.pg20 2/2] pg_locale.c: add explanatory comments and test
the examples.
Explain the purpose and caveats of case conversion functions, rather
than just the API. Also add tests to cover the examples.
Reviewed-by: Andres Freund <[email protected]>
Suggested-by: Andres Freund <[email protected]>
Discussion: https://postgr.es/m/v36ssaygf7grb3qzfsjhtdzi7kqd45ds56nyuf7gi5qjml4qbb@ezmfqzmhlrs2
Backpatch-through: 18
---
src/backend/utils/adt/pg_locale.c | 72 +++++++++++++++++++++-
src/test/regress/expected/collate.utf8.out | 8 ++-
src/test/regress/sql/collate.utf8.sql | 4 +-
3 files changed, 80 insertions(+), 4 deletions(-)
diff --git a/src/backend/utils/adt/pg_locale.c b/src/backend/utils/adt/pg_locale.c
index b6e6ee8928b..af9fdd87bf4 100644
--- a/src/backend/utils/adt/pg_locale.c
+++ b/src/backend/utils/adt/pg_locale.c
@@ -1317,12 +1317,58 @@ strupper_c(char *dst, size_t dstsize, const char *src, size_t srclen)
return srclen;
}
+/*
+ * Case Mapping Complexities
+ *
+ * pg_strlower(), pg_strtitle(), pg_strupper(), and pg_strfold() are based on
+ * case mapping. Below are general notes on the complexities of Unicode case
+ * mapping, which are relevant to most locales other than "C", but which vary
+ * significantly among different providers and locales. See the Unicode
+ * Standard and the provider implementation for details.
+ *
+ * Some characters map to more than one other character, so the result may
+ * have more characters than the original. Unicode defines the maximum string
+ * expansion to be 3x the code points (not necessarily 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".
+ *
+ * Some characters have more than two forms, e.g. U+03A3 GREEK CAPITAL LETTER
+ * SIGMA, U+03C3 GREEK SMALL LETTER SIGMA, and U+03C2 GREEK SMALL LETTER FINAL
+ * SIGMA (the first is uppercase and the latter two are both lowercase). The
+ * form used depends on context within the string.
+ *
+ * Some characters have special titlecase forms to use for the initial letter
+ * of a word, if available; otherwise uppercase is used. Example: U+01F2
+ * LATIN CAPITAL LETTER D WITH SMALL LETTER Z.
+ *
+ * Titlecasing requires finding a word boundary, which is dependent on the
+ * provider and locale. The semantics of identifying word boundaries may
+ * differ from the semantics used to choose a particular form (e.g. U+03C3
+ * GREEK SMALL LETTER SIGMA vs. U+03C2 GREEK SMALL LETTER FINAL SIGMA).
+ *
+ * Mappings may depend on the provider and the version of Unicode on which it
+ * is based. If mapping only assigned code points, the results of casefolding
+ * are guaranteed to be stable across Unicode versions. Unassigned code
+ * points map to themselves, so are subject to change if the provider updates
+ * Unicode. Therefore, casefolding strings of assigned code points is the
+ * safest mapping for callers that will store the result, e.g. an expression
+ * index.
+ */
+
/*
* pg_strlower()
*
* Convert src to lowercase, and return the result length (not including
* terminating NUL).
*
+ * 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.
@@ -1347,6 +1393,13 @@ pg_strlower(char *dst, size_t dstsize, const char *src, size_t srclen,
* Convert src to titlecase, and return the result length (not including
* terminating NUL).
*
+ * Titlecasing is intended for human-readable display. A titlecase string has
+ * the initial letter of each word uppercased (or changed to a special
+ * 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.
@@ -1371,6 +1424,11 @@ pg_strtitle(char *dst, size_t dstsize, const char *src, size_t srclen,
* Convert src to uppercase, and return the result length (not including
* terminating NUL).
*
+ * 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.
@@ -1392,7 +1450,19 @@ pg_strupper(char *dst, size_t dstsize, const char *src, size_t srclen,
/*
* pg_strfold()
*
- * Casefold src, and return the result length (not including terminating NUL).
+ * Casefold src, and return the result length (not including terminating
+ * NUL).
+ *
+ * Casefolding produces a canonical string such that, iff the casefolded
+ * strings are equal, the original strings are a case-insensitive match (the
+ * strength of this guarantee depends on normalization, provider and locale).
+ * In practice the result is similar to lowercasing, but the purpose is
+ * different: lowercasing is for human-readable display; whereas casefolding
+ * is meant to canonicalize complex mappings reliably without regard for
+ * 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
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