On Thu, 2026-08-13 at 22:17 -0700, Jeff Davis wrote:
> On Tue, 2026-08-11 at 14:31 -0400, Andres Freund wrote:
> > Hm. If I infer the pg_strlower() API correctly - it's utterly
> > underdocumented
>
> Agreed. Patch attached.
>
> > I'd also make i size_t, given that the input is size_t. Perhaps
> > practically
> > no problem, but I see no reason to not use size_t here.
>
> Patch attached for that, too.
>
> I also attached patches to make all the functions work with
> collate_is_c, and fixed up the -1 API in 18.
Now with a C test module (made with AI assistance).
I plan to start committing these fairly soon. I'm not sure whether to
backport the C test module, but I included the patches to do so.
Regards,
Jeff Davis
From 77e5ec7124d4530fead31794cfee62905d7254d2 Mon Sep 17 00:00:00 2001
From: Jeff Davis <[email protected]>
Date: Thu, 13 Aug 2026 20:57:20 -0700
Subject: [PATCH vPG18 1/5] Fixup 5f003855e7 for srclen < 0.
No actual problem because no callers used that aspect of the API.
Only commit to 18, because that part of the API was removed in commit
6d22c67c3b.
Discussion: https://postgr.es/m/v36ssaygf7grb3qzfsjhtdzi7kqd45ds56nyuf7gi5qjml4qbb@ezmfqzmhlrs2
---
src/backend/utils/adt/pg_locale.c | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/src/backend/utils/adt/pg_locale.c b/src/backend/utils/adt/pg_locale.c
index 2f8d5fea8f2..9c721efb52f 100644
--- a/src/backend/utils/adt/pg_locale.c
+++ b/src/backend/utils/adt/pg_locale.c
@@ -1328,6 +1328,8 @@ size_t
pg_strlower(char *dst, size_t dstsize, const char *src, ssize_t srclen,
pg_locale_t locale)
{
+ srclen = (srclen < 0) ? strlen(src) : srclen;
+
if (locale->ctype_is_c)
return strlower_c(dst, dstsize, src, srclen);
else if (locale->provider == COLLPROVIDER_BUILTIN)
@@ -1349,6 +1351,8 @@ size_t
pg_strtitle(char *dst, size_t dstsize, const char *src, ssize_t srclen,
pg_locale_t locale)
{
+ srclen = (srclen < 0) ? strlen(src) : srclen;
+
if (locale->ctype_is_c)
return strtitle_c(dst, dstsize, src, srclen);
else if (locale->provider == COLLPROVIDER_BUILTIN)
@@ -1370,6 +1374,8 @@ size_t
pg_strupper(char *dst, size_t dstsize, const char *src, ssize_t srclen,
pg_locale_t locale)
{
+ srclen = (srclen < 0) ? strlen(src) : srclen;
+
if (locale->ctype_is_c)
return strupper_c(dst, dstsize, src, srclen);
else if (locale->provider == COLLPROVIDER_BUILTIN)
@@ -1391,6 +1397,8 @@ size_t
pg_strfold(char *dst, size_t dstsize, const char *src, ssize_t srclen,
pg_locale_t locale)
{
+ srclen = (srclen < 0) ? strlen(src) : srclen;
+
/* in the C locale, casefolding is the same as lowercasing */
if (locale->ctype_is_c)
return strlower_c(dst, dstsize, src, srclen);
--
2.43.0
From f669e9fafb9bf435c946dcc2c2dd98efb4aa46cb Mon Sep 17 00:00:00 2001
From: Jeff Davis <[email protected]>
Date: Thu, 13 Aug 2026 21:12:34 -0700
Subject: [PATCH vPG18 2/5] pg_locale.c, unicode_case.c: use size_t for
iteration.
No actual problem, just cleanup. Only relevant to 18 and 19.
Suggested-by: Andres Freund <[email protected]>
Discussion: https://postgr.es/m/v36ssaygf7grb3qzfsjhtdzi7kqd45ds56nyuf7gi5qjml4qbb@ezmfqzmhlrs2
---
src/backend/utils/adt/pg_locale.c | 6 +++---
src/common/unicode_case.c | 4 ++--
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/backend/utils/adt/pg_locale.c b/src/backend/utils/adt/pg_locale.c
index 9c721efb52f..8e79ccbd9f6 100644
--- a/src/backend/utils/adt/pg_locale.c
+++ b/src/backend/utils/adt/pg_locale.c
@@ -1277,7 +1277,7 @@ get_collation_actual_version(char collprovider, const char *collcollate)
static size_t
strlower_c(char *dst, size_t dstsize, const char *src, size_t srclen)
{
- int i;
+ size_t i;
for (i = 0; i < srclen && i < dstsize; i++)
dst[i] = pg_ascii_tolower(src[i]);
@@ -1291,7 +1291,7 @@ static size_t
strtitle_c(char *dst, size_t dstsize, const char *src, size_t srclen)
{
bool wasalnum = false;
- int i;
+ size_t i;
for (i = 0; i < srclen && i < dstsize; i++)
{
@@ -1315,7 +1315,7 @@ strtitle_c(char *dst, size_t dstsize, const char *src, size_t srclen)
static size_t
strupper_c(char *dst, size_t dstsize, const char *src, size_t srclen)
{
- int i;
+ size_t i;
for (i = 0; i < srclen && i < dstsize; i++)
dst[i] = pg_ascii_toupper(src[i]);
diff --git a/src/common/unicode_case.c b/src/common/unicode_case.c
index 8639b203e0c..86e0b57d7d3 100644
--- a/src/common/unicode_case.c
+++ b/src/common/unicode_case.c
@@ -341,7 +341,7 @@ check_final_sigma(const unsigned char *str, size_t len, size_t offset)
int ulen;
/* iterate backwards looking for preceding character */
- for (int i = offset; i > 0;)
+ for (size_t i = offset; i > 0;)
{
/* skip backwards through continuation bytes */
i--;
@@ -369,7 +369,7 @@ check_final_sigma(const unsigned char *str, size_t len, size_t offset)
ulen = utf8_mblen((const unsigned char *) str + offset);
/* iterate forward looking for following character */
- for (int i = offset + ulen; i < len;)
+ for (size_t i = offset + ulen; i < len;)
{
ulen = utf8_mblen((const unsigned char *) str + i);
--
2.43.0
From a8e72bb34c979ac43ee8480fd10ed4e73db47b10 Mon Sep 17 00:00:00 2001
From: Jeff Davis <[email protected]>
Date: Wed, 12 Aug 2026 07:32:19 -0700
Subject: [PATCH vPG18 3/5] Add missing comments in pg_locale.c.
Suggested-by: Andres Freund <[email protected]>
Discussion: https://postgr.es/m/v3nniwcrxejmcfvz56xbd22hphprqleuornd6hqkmw2bl7kgmz@cnytz2ee5ltk
Backpatch-through: 18
---
src/backend/utils/adt/pg_locale.c | 76 +++++++++++++++++++++++++------
1 file changed, 63 insertions(+), 13 deletions(-)
diff --git a/src/backend/utils/adt/pg_locale.c b/src/backend/utils/adt/pg_locale.c
index 8e79ccbd9f6..c21619f85cd 100644
--- a/src/backend/utils/adt/pg_locale.c
+++ b/src/backend/utils/adt/pg_locale.c
@@ -1324,6 +1324,20 @@ strupper_c(char *dst, size_t dstsize, const char *src, size_t srclen)
return srclen;
}
+/*
+ * pg_strlower()
+ *
+ * Convert src to lowercase, and return the result length (not including
+ * terminating NUL).
+ *
+ * src must be in the database encoding with no embedded NULs. If srclen is
+ * -1, src must be NUL-terminated. If dstsize is zero, dst may be NULL, which
+ * is useful for calculating the required buffer size before allocating.
+ *
+ * If the result length is less than dstsize, the NUL-terminated result is
+ * stored in dst. Otherwise, the contents of dst are undefined, and the
+ * caller should use the return value to resize the buffer and retry.
+ */
size_t
pg_strlower(char *dst, size_t dstsize, const char *src, ssize_t srclen,
pg_locale_t locale)
@@ -1347,6 +1361,20 @@ pg_strlower(char *dst, size_t dstsize, const char *src, ssize_t srclen,
return 0; /* keep compiler quiet */
}
+/*
+ * pg_strtitle()
+ *
+ * Convert src to titlecase, and return the result length (not including
+ * terminating NUL).
+ *
+ * src must be in the database encoding with no embedded NULs. If srclen is
+ * -1, src must be NUL-terminated. If dstsize is zero, dst may be NULL, which
+ * is useful for calculating the required buffer size before allocating.
+ *
+ * If the result length is less than dstsize, the NUL-terminated result is
+ * stored in dst. Otherwise, the contents of dst are undefined, and the
+ * caller should use the return value to resize the buffer and retry.
+ */
size_t
pg_strtitle(char *dst, size_t dstsize, const char *src, ssize_t srclen,
pg_locale_t locale)
@@ -1370,6 +1398,20 @@ pg_strtitle(char *dst, size_t dstsize, const char *src, ssize_t srclen,
return 0; /* keep compiler quiet */
}
+/*
+ * pg_strupper()
+ *
+ * Convert src to uppercase, and return the result length (not including
+ * terminating NUL).
+ *
+ * src must be in the database encoding with no embedded NULs. If srclen is
+ * -1, src must be NUL-terminated. If dstsize is zero, dst may be NULL, which
+ * is useful for calculating the required buffer size before allocating.
+ *
+ * If the result length is less than dstsize, the NUL-terminated result is
+ * stored in dst. Otherwise, the contents of dst are undefined, and the
+ * caller should use the return value to resize the buffer and retry.
+ */
size_t
pg_strupper(char *dst, size_t dstsize, const char *src, ssize_t srclen,
pg_locale_t locale)
@@ -1393,6 +1435,19 @@ pg_strupper(char *dst, size_t dstsize, const char *src, ssize_t srclen,
return 0; /* keep compiler quiet */
}
+/*
+ * pg_strfold()
+ *
+ * Casefold src, and return the result length (not including terminating NUL).
+ *
+ * src must be in the database encoding with no embedded NULs. If srclen is
+ * -1, src must be NUL-terminated. If dstsize is zero, dst may be NULL, which
+ * is useful for calculating the required buffer size before allocating.
+ *
+ * If the result length is less than dstsize, the NUL-terminated result is
+ * stored in dst. Otherwise, the contents of dst are undefined, and the
+ * caller should use the return value to resize the buffer and retry.
+ */
size_t
pg_strfold(char *dst, size_t dstsize, const char *src, ssize_t srclen,
pg_locale_t locale)
@@ -1432,12 +1487,10 @@ pg_strcoll(const char *arg1, const char *arg2, pg_locale_t locale)
/*
* pg_strncoll
*
- * Call ucol_strcollUTF8(), ucol_strcoll(), strcoll_l() or wcscoll_l() as
- * appropriate for the given locale, platform, and database encoding. If the
- * locale is not specified, use the database collation.
+ * Compare strings according to the given locale.
*
- * The input strings must be encoded in the database encoding. If an input
- * string is NUL-terminated, its length may be specified as -1.
+ * Strings must be encoded in the database encoding with no embedded NULs. If
+ * an input string is NUL-terminated, its length may be specified as -1.
*
* The caller is responsible for breaking ties if the collation is
* deterministic; this maintains consistency with pg_strnxfrm(), which cannot
@@ -1453,9 +1506,6 @@ pg_strncoll(const char *arg1, ssize_t len1, const char *arg2, ssize_t len2,
/*
* Return true if the collation provider supports pg_strxfrm() and
* pg_strnxfrm(); otherwise false.
- *
- *
- * No similar problem is known for the ICU provider.
*/
bool
pg_strxfrm_enabled(pg_locale_t locale)
@@ -1486,9 +1536,9 @@ pg_strxfrm(char *dest, const char *src, size_t destsize, pg_locale_t locale)
* ordinary strcmp() on transformed strings is equivalent to pg_strcoll() on
* untransformed strings.
*
- * The input string must be encoded in the database encoding. If the input
- * string is NUL-terminated, its length may be specified as -1. If 'destsize'
- * is zero, 'dest' may be NULL.
+ * String must be encoded in the database encoding with no embedded NULs. If
+ * srclen is -1, src must be NUL-terminated. If 'destsize' is zero, 'dest'
+ * may be NULL.
*
* Not all providers support pg_strnxfrm() safely. The caller should check
* pg_strxfrm_enabled() first, otherwise this function may return wrong
@@ -1534,8 +1584,8 @@ pg_strxfrm_prefix(char *dest, const char *src, size_t destsize,
* memcmp() on the byte sequence is equivalent to pg_strncoll() on
* untransformed strings. The result is not nul-terminated.
*
- * The input string must be encoded in the database encoding. If the input
- * string is NUL-terminated, its length may be specified as -1.
+ * String must be encoded in the database encoding with no embedded NULs. If
+ * the input string is NUL-terminated, its length may be specified as -1.
*
* Not all providers support pg_strnxfrm_prefix() safely. The caller should
* check pg_strxfrm_prefix_enabled() first, otherwise this function may return
--
2.43.0
From a578ea2245f071f3ba7897ce3063c8926ba765b5 Mon Sep 17 00:00:00 2001
From: Jeff Davis <[email protected]>
Date: Wed, 12 Aug 2026 07:46:48 -0700
Subject: [PATCH vPG18 4/5] Ensure all pg_locale.h APIs work with collate_is_c.
Suggested-by: Andres Freund <[email protected]>
Discussion: https://postgr.es/m/v36ssaygf7grb3qzfsjhtdzi7kqd45ds56nyuf7gi5qjml4qbb@ezmfqzmhlrs2
Backpatch-through: 18
---
src/backend/utils/adt/pg_locale.c | 64 ++++++++++++++++++++++++++++---
1 file changed, 58 insertions(+), 6 deletions(-)
diff --git a/src/backend/utils/adt/pg_locale.c b/src/backend/utils/adt/pg_locale.c
index c21619f85cd..ad9f416ec13 100644
--- a/src/backend/utils/adt/pg_locale.c
+++ b/src/backend/utils/adt/pg_locale.c
@@ -1481,7 +1481,10 @@ pg_strfold(char *dst, size_t dstsize, const char *src, ssize_t srclen,
int
pg_strcoll(const char *arg1, const char *arg2, pg_locale_t locale)
{
- return locale->collate->strncoll(arg1, -1, arg2, -1, locale);
+ if (locale->collate == NULL)
+ return strcmp(arg1, arg2);
+ else
+ return locale->collate->strncoll(arg1, -1, arg2, -1, locale);
}
/*
@@ -1500,7 +1503,20 @@ int
pg_strncoll(const char *arg1, ssize_t len1, const char *arg2, ssize_t len2,
pg_locale_t locale)
{
- return locale->collate->strncoll(arg1, len1, arg2, len2, locale);
+ if (locale->collate == NULL)
+ {
+ int result;
+
+ len1 = (len1 < 0) ? strlen(arg1) : len1;
+ len2 = (len2 < 0) ? strlen(arg2) : len2;
+ result = memcmp(arg1, arg2, Min(len1, len2));
+
+ if ((result == 0) && (len1 != len2))
+ result = (len1 < len2) ? -1 : 1;
+ return result;
+ }
+ else
+ return locale->collate->strncoll(arg1, len1, arg2, len2, locale);
}
/*
@@ -1510,6 +1526,9 @@ pg_strncoll(const char *arg1, ssize_t len1, const char *arg2, ssize_t len2,
bool
pg_strxfrm_enabled(pg_locale_t locale)
{
+ if (locale->collate == NULL)
+ return true;
+
/*
* locale->collate->strnxfrm is still a required method, even if it may
* have the wrong behavior, because the planner uses it for estimates in
@@ -1526,7 +1545,10 @@ pg_strxfrm_enabled(pg_locale_t locale)
size_t
pg_strxfrm(char *dest, const char *src, size_t destsize, pg_locale_t locale)
{
- return locale->collate->strnxfrm(dest, destsize, src, -1, locale);
+ if (locale->collate == NULL)
+ return pg_strnxfrm(dest, destsize, src, strlen(src), locale);
+ else
+ return locale->collate->strnxfrm(dest, destsize, src, -1, locale);
}
/*
@@ -1552,6 +1574,18 @@ size_t
pg_strnxfrm(char *dest, size_t destsize, const char *src, ssize_t srclen,
pg_locale_t locale)
{
+ if (locale->collate == NULL)
+ {
+ srclen = (srclen < 0) ? strlen(src) : srclen;
+
+ if (destsize > srclen)
+ {
+ memcpy(dest, src, srclen);
+ dest[srclen] = '\0';
+ }
+
+ return srclen;
+ }
return locale->collate->strnxfrm(dest, destsize, src, srclen, locale);
}
@@ -1562,7 +1596,10 @@ pg_strnxfrm(char *dest, size_t destsize, const char *src, ssize_t srclen,
bool
pg_strxfrm_prefix_enabled(pg_locale_t locale)
{
- return (locale->collate->strnxfrm_prefix != NULL);
+ if (locale->collate == NULL)
+ return true;
+ else
+ return (locale->collate->strnxfrm_prefix != NULL);
}
/*
@@ -1574,7 +1611,10 @@ size_t
pg_strxfrm_prefix(char *dest, const char *src, size_t destsize,
pg_locale_t locale)
{
- return locale->collate->strnxfrm_prefix(dest, destsize, src, -1, locale);
+ if (locale->collate == NULL)
+ return pg_strnxfrm_prefix(dest, destsize, src, strlen(src), locale);
+ else
+ return locale->collate->strnxfrm_prefix(dest, destsize, src, -1, locale);
}
/*
@@ -1599,7 +1639,19 @@ size_t
pg_strnxfrm_prefix(char *dest, size_t destsize, const char *src,
ssize_t srclen, pg_locale_t locale)
{
- return locale->collate->strnxfrm_prefix(dest, destsize, src, srclen, locale);
+ if (locale->collate == NULL)
+ {
+ size_t len;
+
+ srclen = (srclen < 0) ? strlen(src) : srclen;
+ len = Min(srclen, destsize);
+
+ if (destsize > 0)
+ memcpy(dest, src, len);
+ return len;
+ }
+ else
+ return locale->collate->strnxfrm_prefix(dest, destsize, src, srclen, locale);
}
/*
--
2.43.0
From adeef54179a11f5b7817054bc53f8b7b584d2b6a Mon Sep 17 00:00:00 2001
From: Jeff Davis <[email protected]>
Date: Sat, 15 Aug 2026 12:26:09 -0700
Subject: [PATCH vPG18 5/5] Add C test module 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.
Discussion: https://postgr.es/m/v36ssaygf7grb3qzfsjhtdzi7kqd45ds56nyuf7gi5qjml4qbb@ezmfqzmhlrs2
Backpatch-through: 18
---
src/test/modules/Makefile | 1 +
src/test/modules/meson.build | 1 +
src/test/modules/test_pg_locale/.gitignore | 4 +
src/test/modules/test_pg_locale/Makefile | 23 +++
src/test/modules/test_pg_locale/README | 2 +
.../expected/test_pg_locale.out | 38 ++++
src/test/modules/test_pg_locale/meson.build | 33 ++++
.../test_pg_locale/sql/test_pg_locale.sql | 23 +++
.../test_pg_locale/test_pg_locale--1.0.sql | 8 +
.../modules/test_pg_locale/test_pg_locale.c | 169 ++++++++++++++++++
.../test_pg_locale/test_pg_locale.control | 4 +
11 files changed, 306 insertions(+)
create mode 100644 src/test/modules/test_pg_locale/.gitignore
create mode 100644 src/test/modules/test_pg_locale/Makefile
create mode 100644 src/test/modules/test_pg_locale/README
create mode 100644 src/test/modules/test_pg_locale/expected/test_pg_locale.out
create mode 100644 src/test/modules/test_pg_locale/meson.build
create mode 100644 src/test/modules/test_pg_locale/sql/test_pg_locale.sql
create mode 100644 src/test/modules/test_pg_locale/test_pg_locale--1.0.sql
create mode 100644 src/test/modules/test_pg_locale/test_pg_locale.c
create mode 100644 src/test/modules/test_pg_locale/test_pg_locale.control
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 4e82d6f1517..9ee74b66797 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -33,6 +33,7 @@ SUBDIRS = \
test_oat_hooks \
test_parser \
test_pg_dump \
+ test_pg_locale \
test_predtest \
test_radixtree \
test_rbtree \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 9a957351ab6..85ef25deadf 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -32,6 +32,7 @@ subdir('test_misc')
subdir('test_oat_hooks')
subdir('test_parser')
subdir('test_pg_dump')
+subdir('test_pg_locale')
subdir('test_predtest')
subdir('test_radixtree')
subdir('test_rbtree')
diff --git a/src/test/modules/test_pg_locale/.gitignore b/src/test/modules/test_pg_locale/.gitignore
new file mode 100644
index 00000000000..5dcb3ff9723
--- /dev/null
+++ b/src/test/modules/test_pg_locale/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/test_pg_locale/Makefile b/src/test/modules/test_pg_locale/Makefile
new file mode 100644
index 00000000000..9b051f8a697
--- /dev/null
+++ b/src/test/modules/test_pg_locale/Makefile
@@ -0,0 +1,23 @@
+# src/test/modules/test_pg_locale/Makefile
+
+MODULE_big = test_pg_locale
+OBJS = \
+ $(WIN32RES) \
+ test_pg_locale.o
+PGFILEDESC = "test_pg_locale - test code for pg_locale.h APIs"
+
+EXTENSION = test_pg_locale
+DATA = test_pg_locale--1.0.sql
+
+REGRESS = test_pg_locale
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_pg_locale
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/test_pg_locale/README b/src/test/modules/test_pg_locale/README
new file mode 100644
index 00000000000..d95af97c005
--- /dev/null
+++ b/src/test/modules/test_pg_locale/README
@@ -0,0 +1,2 @@
+Calls pg_locale.h wrappers directly. Ordinary SQL tests do not reach
+the C-locale fallbacks because in-tree callers special-case collate_is_c.
diff --git a/src/test/modules/test_pg_locale/expected/test_pg_locale.out b/src/test/modules/test_pg_locale/expected/test_pg_locale.out
new file mode 100644
index 00000000000..edbba284552
--- /dev/null
+++ b/src/test/modules/test_pg_locale/expected/test_pg_locale.out
@@ -0,0 +1,38 @@
+CREATE EXTENSION test_pg_locale;
+--
+-- These tests don't produce any interesting output. We're checking that
+-- the operations complete without crashing and that none of their internal
+-- sanity tests fail.
+--
+-- to_regcollation() returns NULL when the collation is absent or is not
+-- usable in the current database encoding. The function is STRICT, so
+-- those cases are skipped.
+--
+-- 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/modules/test_pg_locale/meson.build b/src/test/modules/test_pg_locale/meson.build
new file mode 100644
index 00000000000..f5097464ad2
--- /dev/null
+++ b/src/test/modules/test_pg_locale/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+test_pg_locale_sources = files(
+ 'test_pg_locale.c',
+)
+
+if host_system == 'windows'
+ test_pg_locale_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+ '--NAME', 'test_pg_locale',
+ '--FILEDESC', 'test_pg_locale - test code for pg_locale.h APIs',])
+endif
+
+test_pg_locale = shared_module('test_pg_locale',
+ test_pg_locale_sources,
+ kwargs: pg_test_mod_args,
+)
+test_install_libs += test_pg_locale
+
+test_install_data += files(
+ 'test_pg_locale.control',
+ 'test_pg_locale--1.0.sql',
+)
+
+tests += {
+ 'name': 'test_pg_locale',
+ 'sd': meson.current_source_dir(),
+ 'bd': meson.current_build_dir(),
+ 'regress': {
+ 'sql': [
+ 'test_pg_locale',
+ ],
+ },
+}
diff --git a/src/test/modules/test_pg_locale/sql/test_pg_locale.sql b/src/test/modules/test_pg_locale/sql/test_pg_locale.sql
new file mode 100644
index 00000000000..212012feb9c
--- /dev/null
+++ b/src/test/modules/test_pg_locale/sql/test_pg_locale.sql
@@ -0,0 +1,23 @@
+CREATE EXTENSION test_pg_locale;
+
+--
+-- These tests don't produce any interesting output. We're checking that
+-- the operations complete without crashing and that none of their internal
+-- sanity tests fail.
+--
+-- to_regcollation() returns NULL when the collation is absent or is not
+-- usable in the current database encoding. The function is STRICT, so
+-- those cases are skipped.
+--
+
+-- 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'));
diff --git a/src/test/modules/test_pg_locale/test_pg_locale--1.0.sql b/src/test/modules/test_pg_locale/test_pg_locale--1.0.sql
new file mode 100644
index 00000000000..134c4befa06
--- /dev/null
+++ b/src/test/modules/test_pg_locale/test_pg_locale--1.0.sql
@@ -0,0 +1,8 @@
+/* src/test/modules/test_pg_locale/test_pg_locale--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION test_pg_locale" to load this file. \quit
+
+CREATE FUNCTION test_pg_locale_apis(oid)
+ RETURNS pg_catalog.void
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
diff --git a/src/test/modules/test_pg_locale/test_pg_locale.c b/src/test/modules/test_pg_locale/test_pg_locale.c
new file mode 100644
index 00000000000..79041fb3f69
--- /dev/null
+++ b/src/test/modules/test_pg_locale/test_pg_locale.c
@@ -0,0 +1,169 @@
+/*-------------------------------------------------------------------------
+ *
+ * test_pg_locale.c
+ * Call pg_locale.h wrappers directly.
+ *
+ * SQL callers special-case collate_is_c, so the C-locale fallbacks are
+ * not reached by ordinary regression tests.
+ *
+ * Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ * src/test/modules/test_pg_locale/test_pg_locale.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "utils/pg_locale.h"
+
+PG_MODULE_MAGIC;
+
+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);
+
+ n = pg_strlower(buf, sizeof(buf), "AbC", -1, locale);
+ if (n != 3 || strcmp(buf, "abc") != 0)
+ elog(ERROR, "pg_strlower() with srclen -1 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");
+ }
+
+ if (pg_strncoll("abc", -1, "abc", -1, locale) != 0)
+ elog(ERROR, "pg_strncoll() with length -1 failed");
+ if (locale->collate_is_c)
+ {
+ n = pg_strnxfrm(buf, sizeof(buf), "abc", -1, locale);
+ if (n != 3 || strcmp(buf, "abc") != 0)
+ elog(ERROR, "pg_strnxfrm() with srclen -1 produced \"%s\"", buf);
+ n = pg_strnxfrm_prefix(pfx, sizeof(pfx), "abc", -1, locale);
+ if (n != 3 || memcmp(pfx, "abc", 3) != 0)
+ elog(ERROR, "pg_strnxfrm_prefix() with srclen -1 produced a wrong result");
+ }
+}
+
+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/modules/test_pg_locale/test_pg_locale.control b/src/test/modules/test_pg_locale/test_pg_locale.control
new file mode 100644
index 00000000000..6b224d04a1b
--- /dev/null
+++ b/src/test/modules/test_pg_locale/test_pg_locale.control
@@ -0,0 +1,4 @@
+comment = 'Test code for pg_locale.h APIs'
+default_version = '1.0'
+module_pathname = '$libdir/test_pg_locale'
+relocatable = true
--
2.43.0
From 3d63edf5d0132614d8007845220517cd6dfd2f37 Mon Sep 17 00:00:00 2001
From: Jeff Davis <[email protected]>
Date: Thu, 13 Aug 2026 21:12:34 -0700
Subject: [PATCH vPG19 1/4] pg_locale.c, unicode_case.c: use size_t for
iteration.
No actual problem, just cleanup. Only relevant to 18 and 19.
Suggested-by: Andres Freund <[email protected]>
Discussion: https://postgr.es/m/v36ssaygf7grb3qzfsjhtdzi7kqd45ds56nyuf7gi5qjml4qbb@ezmfqzmhlrs2
---
src/backend/utils/adt/pg_locale.c | 6 +++---
src/common/unicode_case.c | 4 ++--
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/backend/utils/adt/pg_locale.c b/src/backend/utils/adt/pg_locale.c
index 11d48a3916e..9eb99487e57 100644
--- a/src/backend/utils/adt/pg_locale.c
+++ b/src/backend/utils/adt/pg_locale.c
@@ -1270,7 +1270,7 @@ get_collation_actual_version(char collprovider, const char *collcollate)
static size_t
strlower_c(char *dst, size_t dstsize, const char *src, size_t srclen)
{
- int i;
+ size_t i;
for (i = 0; i < srclen && i < dstsize; i++)
dst[i] = pg_ascii_tolower(src[i]);
@@ -1284,7 +1284,7 @@ static size_t
strtitle_c(char *dst, size_t dstsize, const char *src, size_t srclen)
{
bool wasalnum = false;
- int i;
+ size_t i;
for (i = 0; i < srclen && i < dstsize; i++)
{
@@ -1308,7 +1308,7 @@ strtitle_c(char *dst, size_t dstsize, const char *src, size_t srclen)
static size_t
strupper_c(char *dst, size_t dstsize, const char *src, size_t srclen)
{
- int i;
+ size_t i;
for (i = 0; i < srclen && i < dstsize; i++)
dst[i] = pg_ascii_toupper(src[i]);
diff --git a/src/common/unicode_case.c b/src/common/unicode_case.c
index dd5b3ba86d0..744b9116b12 100644
--- a/src/common/unicode_case.c
+++ b/src/common/unicode_case.c
@@ -336,7 +336,7 @@ check_final_sigma(const unsigned char *str, size_t len, size_t offset)
int ulen;
/* iterate backwards looking for preceding character */
- for (int i = offset; i > 0;)
+ for (size_t i = offset; i > 0;)
{
/* skip backwards through continuation bytes */
i--;
@@ -364,7 +364,7 @@ check_final_sigma(const unsigned char *str, size_t len, size_t offset)
ulen = utf8_mblen((const unsigned char *) str + offset);
/* iterate forward looking for following character */
- for (int i = offset + ulen; i < len;)
+ for (size_t i = offset + ulen; i < len;)
{
ulen = utf8_mblen((const unsigned char *) str + i);
--
2.43.0
From 2d63e62bc454828630f68aa5bfff486a47540bff Mon Sep 17 00:00:00 2001
From: Jeff Davis <[email protected]>
Date: Wed, 12 Aug 2026 07:32:19 -0700
Subject: [PATCH vPG19 2/4] Add missing comments in pg_locale.c.
Suggested-by: Andres Freund <[email protected]>
Discussion: https://postgr.es/m/v3nniwcrxejmcfvz56xbd22hphprqleuornd6hqkmw2bl7kgmz@cnytz2ee5ltk
Backpatch-through: 18
---
src/backend/utils/adt/pg_locale.c | 70 ++++++++++++++++++++++++++-----
1 file changed, 60 insertions(+), 10 deletions(-)
diff --git a/src/backend/utils/adt/pg_locale.c b/src/backend/utils/adt/pg_locale.c
index 9eb99487e57..da32590396f 100644
--- a/src/backend/utils/adt/pg_locale.c
+++ b/src/backend/utils/adt/pg_locale.c
@@ -1317,6 +1317,20 @@ strupper_c(char *dst, size_t dstsize, const char *src, size_t srclen)
return srclen;
}
+/*
+ * pg_strlower()
+ *
+ * Convert src to lowercase, and return the result length (not including
+ * terminating NUL).
+ *
+ * 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.
+ *
+ * If the result length is less than dstsize, the NUL-terminated result is
+ * stored in dst. Otherwise, the contents of dst are undefined, and the
+ * caller should use the return value to resize the buffer and retry.
+ */
size_t
pg_strlower(char *dst, size_t dstsize, const char *src, size_t srclen,
pg_locale_t locale)
@@ -1327,6 +1341,20 @@ pg_strlower(char *dst, size_t dstsize, const char *src, size_t srclen,
return locale->ctype->strlower(dst, dstsize, src, srclen, locale);
}
+/*
+ * pg_strtitle()
+ *
+ * Convert src to titlecase, and return the result length (not including
+ * terminating NUL).
+ *
+ * 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.
+ *
+ * If the result length is less than dstsize, the NUL-terminated result is
+ * stored in dst. Otherwise, the contents of dst are undefined, and the
+ * caller should use the return value to resize the buffer and retry.
+ */
size_t
pg_strtitle(char *dst, size_t dstsize, const char *src, size_t srclen,
pg_locale_t locale)
@@ -1337,6 +1365,20 @@ pg_strtitle(char *dst, size_t dstsize, const char *src, size_t srclen,
return locale->ctype->strtitle(dst, dstsize, src, srclen, locale);
}
+/*
+ * pg_strupper()
+ *
+ * Convert src to uppercase, and return the result length (not including
+ * terminating NUL).
+ *
+ * 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.
+ *
+ * If the result length is less than dstsize, the NUL-terminated result is
+ * stored in dst. Otherwise, the contents of dst are undefined, and the
+ * caller should use the return value to resize the buffer and retry.
+ */
size_t
pg_strupper(char *dst, size_t dstsize, const char *src, size_t srclen,
pg_locale_t locale)
@@ -1347,6 +1389,19 @@ pg_strupper(char *dst, size_t dstsize, const char *src, size_t srclen,
return locale->ctype->strupper(dst, dstsize, src, srclen, locale);
}
+/*
+ * pg_strfold()
+ *
+ * Casefold src, and return the result length (not including terminating NUL).
+ *
+ * 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.
+ *
+ * If the result length is less than dstsize, the NUL-terminated result is
+ * stored in dst. Otherwise, the contents of dst are undefined, and the
+ * caller should use the return value to resize the buffer and retry.
+ */
size_t
pg_strfold(char *dst, size_t dstsize, const char *src, size_t srclen,
pg_locale_t locale)
@@ -1392,11 +1447,9 @@ pg_strcoll(const char *arg1, const char *arg2, pg_locale_t locale)
/*
* pg_strncoll
*
- * Call ucol_strcollUTF8(), ucol_strcoll(), strcoll_l() or wcscoll_l() as
- * appropriate for the given locale, platform, and database encoding. If the
- * locale is not specified, use the database collation.
+ * Compare strings according to the given locale.
*
- * The input strings must be encoded in the database encoding.
+ * Strings must be encoded in the database encoding with no embedded NULs.
*
* The caller is responsible for breaking ties if the collation is
* deterministic; this maintains consistency with pg_strnxfrm(), which cannot
@@ -1412,9 +1465,6 @@ pg_strncoll(const char *arg1, size_t len1, const char *arg2, size_t len2,
/*
* Return true if the collation provider supports pg_strxfrm() and
* pg_strnxfrm(); otherwise false.
- *
- *
- * No similar problem is known for the ICU provider.
*/
bool
pg_strxfrm_enabled(pg_locale_t locale)
@@ -1445,8 +1495,8 @@ pg_strxfrm(char *dest, const char *src, size_t destsize, pg_locale_t locale)
* ordinary strcmp() on transformed strings is equivalent to pg_strcoll() on
* untransformed strings.
*
- * The input string must be encoded in the database encoding. If 'destsize' is
- * zero, 'dest' may be NULL.
+ * String must be encoded in the database encoding with no embedded NULs. If
+ * 'destsize' is zero, 'dest' may be NULL.
*
* Not all providers support pg_strnxfrm() safely. The caller should check
* pg_strxfrm_enabled() first, otherwise this function may return wrong
@@ -1492,7 +1542,7 @@ pg_strxfrm_prefix(char *dest, const char *src, size_t destsize,
* memcmp() on the byte sequence is equivalent to pg_strncoll() on
* untransformed strings. The result is not nul-terminated.
*
- * The input string must be encoded in the database encoding.
+ * String must be encoded in the database encoding with no embedded NULs.
*
* Not all providers support pg_strnxfrm_prefix() safely. The caller should
* check pg_strxfrm_prefix_enabled() first, otherwise this function may return
--
2.43.0
From a93385298c5350b85d2b9033b5ea88d0775e383d Mon Sep 17 00:00:00 2001
From: Jeff Davis <[email protected]>
Date: Wed, 12 Aug 2026 07:46:48 -0700
Subject: [PATCH vPG19 3/4] Ensure all pg_locale.h APIs work with collate_is_c.
Suggested-by: Andres Freund <[email protected]>
Discussion: https://postgr.es/m/v36ssaygf7grb3qzfsjhtdzi7kqd45ds56nyuf7gi5qjml4qbb@ezmfqzmhlrs2
Backpatch-through: 18
---
src/backend/utils/adt/pg_locale.c | 55 +++++++++++++++++++++++++++----
1 file changed, 49 insertions(+), 6 deletions(-)
diff --git a/src/backend/utils/adt/pg_locale.c b/src/backend/utils/adt/pg_locale.c
index da32590396f..2b501a7b6b7 100644
--- a/src/backend/utils/adt/pg_locale.c
+++ b/src/backend/utils/adt/pg_locale.c
@@ -1441,7 +1441,10 @@ pg_downcase_ident(char *dst, size_t dstsize, const char *src, size_t srclen)
int
pg_strcoll(const char *arg1, const char *arg2, pg_locale_t locale)
{
- return locale->collate->strcoll(arg1, arg2, locale);
+ if (locale->collate == NULL)
+ return strcmp(arg1, arg2);
+ else
+ return locale->collate->strcoll(arg1, arg2, locale);
}
/*
@@ -1459,7 +1462,16 @@ int
pg_strncoll(const char *arg1, size_t len1, const char *arg2, size_t len2,
pg_locale_t locale)
{
- return locale->collate->strncoll(arg1, len1, arg2, len2, locale);
+ if (locale->collate == NULL)
+ {
+ int result = memcmp(arg1, arg2, Min(len1, len2));
+
+ if ((result == 0) && (len1 != len2))
+ result = (len1 < len2) ? -1 : 1;
+ return result;
+ }
+ else
+ return locale->collate->strncoll(arg1, len1, arg2, len2, locale);
}
/*
@@ -1469,6 +1481,9 @@ pg_strncoll(const char *arg1, size_t len1, const char *arg2, size_t len2,
bool
pg_strxfrm_enabled(pg_locale_t locale)
{
+ if (locale->collate == NULL)
+ return true;
+
/*
* locale->collate->strnxfrm is still a required method, even if it may
* have the wrong behavior, because the planner uses it for estimates in
@@ -1485,7 +1500,10 @@ pg_strxfrm_enabled(pg_locale_t locale)
size_t
pg_strxfrm(char *dest, const char *src, size_t destsize, pg_locale_t locale)
{
- return locale->collate->strxfrm(dest, destsize, src, locale);
+ if (locale->collate == NULL)
+ return pg_strnxfrm(dest, destsize, src, strlen(src), locale);
+ else
+ return locale->collate->strxfrm(dest, destsize, src, locale);
}
/*
@@ -1510,6 +1528,16 @@ size_t
pg_strnxfrm(char *dest, size_t destsize, const char *src, size_t srclen,
pg_locale_t locale)
{
+ if (locale->collate == NULL)
+ {
+ if (destsize > srclen)
+ {
+ memcpy(dest, src, srclen);
+ dest[srclen] = '\0';
+ }
+
+ return srclen;
+ }
return locale->collate->strnxfrm(dest, destsize, src, srclen, locale);
}
@@ -1520,7 +1548,10 @@ pg_strnxfrm(char *dest, size_t destsize, const char *src, size_t srclen,
bool
pg_strxfrm_prefix_enabled(pg_locale_t locale)
{
- return (locale->collate->strnxfrm_prefix != NULL);
+ if (locale->collate == NULL)
+ return true;
+ else
+ return (locale->collate->strnxfrm_prefix != NULL);
}
/*
@@ -1532,7 +1563,10 @@ size_t
pg_strxfrm_prefix(char *dest, const char *src, size_t destsize,
pg_locale_t locale)
{
- return locale->collate->strxfrm_prefix(dest, destsize, src, locale);
+ if (locale->collate == NULL)
+ return pg_strnxfrm_prefix(dest, destsize, src, strlen(src), locale);
+ else
+ return locale->collate->strxfrm_prefix(dest, destsize, src, locale);
}
/*
@@ -1556,7 +1590,16 @@ size_t
pg_strnxfrm_prefix(char *dest, size_t destsize, const char *src,
size_t srclen, pg_locale_t locale)
{
- return locale->collate->strnxfrm_prefix(dest, destsize, src, srclen, locale);
+ if (locale->collate == NULL)
+ {
+ size_t len = Min(srclen, destsize);
+
+ if (destsize > 0)
+ memcpy(dest, src, len);
+ return len;
+ }
+ else
+ return locale->collate->strnxfrm_prefix(dest, destsize, src, srclen, locale);
}
bool
--
2.43.0
From d59ba32bd3a72442a49aee71a740784a1087b94b Mon Sep 17 00:00:00 2001
From: Jeff Davis <[email protected]>
Date: Sat, 15 Aug 2026 12:26:09 -0700
Subject: [PATCH vPG19 4/4] Add C test module 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.
Discussion: https://postgr.es/m/v36ssaygf7grb3qzfsjhtdzi7kqd45ds56nyuf7gi5qjml4qbb@ezmfqzmhlrs2
Backpatch-through: 18
---
src/test/modules/Makefile | 1 +
src/test/modules/meson.build | 1 +
src/test/modules/test_pg_locale/.gitignore | 4 +
src/test/modules/test_pg_locale/Makefile | 23 +++
src/test/modules/test_pg_locale/README | 2 +
.../expected/test_pg_locale.out | 38 +++++
src/test/modules/test_pg_locale/meson.build | 33 ++++
.../test_pg_locale/sql/test_pg_locale.sql | 23 +++
.../test_pg_locale/test_pg_locale--1.0.sql | 8 +
.../modules/test_pg_locale/test_pg_locale.c | 153 ++++++++++++++++++
.../test_pg_locale/test_pg_locale.control | 4 +
11 files changed, 290 insertions(+)
create mode 100644 src/test/modules/test_pg_locale/.gitignore
create mode 100644 src/test/modules/test_pg_locale/Makefile
create mode 100644 src/test/modules/test_pg_locale/README
create mode 100644 src/test/modules/test_pg_locale/expected/test_pg_locale.out
create mode 100644 src/test/modules/test_pg_locale/meson.build
create mode 100644 src/test/modules/test_pg_locale/sql/test_pg_locale.sql
create mode 100644 src/test/modules/test_pg_locale/test_pg_locale--1.0.sql
create mode 100644 src/test/modules/test_pg_locale/test_pg_locale.c
create mode 100644 src/test/modules/test_pg_locale/test_pg_locale.control
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 098bb8142ae..8a2b09bd11e 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -41,6 +41,7 @@ SUBDIRS = \
test_oat_hooks \
test_parser \
test_pg_dump \
+ test_pg_locale \
test_plan_advice \
test_predtest \
test_radixtree \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4bca42bb370..71c4035b1b3 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -42,6 +42,7 @@ subdir('test_misc')
subdir('test_oat_hooks')
subdir('test_parser')
subdir('test_pg_dump')
+subdir('test_pg_locale')
subdir('test_plan_advice')
subdir('test_predtest')
subdir('test_radixtree')
diff --git a/src/test/modules/test_pg_locale/.gitignore b/src/test/modules/test_pg_locale/.gitignore
new file mode 100644
index 00000000000..5dcb3ff9723
--- /dev/null
+++ b/src/test/modules/test_pg_locale/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/test_pg_locale/Makefile b/src/test/modules/test_pg_locale/Makefile
new file mode 100644
index 00000000000..9b051f8a697
--- /dev/null
+++ b/src/test/modules/test_pg_locale/Makefile
@@ -0,0 +1,23 @@
+# src/test/modules/test_pg_locale/Makefile
+
+MODULE_big = test_pg_locale
+OBJS = \
+ $(WIN32RES) \
+ test_pg_locale.o
+PGFILEDESC = "test_pg_locale - test code for pg_locale.h APIs"
+
+EXTENSION = test_pg_locale
+DATA = test_pg_locale--1.0.sql
+
+REGRESS = test_pg_locale
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_pg_locale
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/test_pg_locale/README b/src/test/modules/test_pg_locale/README
new file mode 100644
index 00000000000..d95af97c005
--- /dev/null
+++ b/src/test/modules/test_pg_locale/README
@@ -0,0 +1,2 @@
+Calls pg_locale.h wrappers directly. Ordinary SQL tests do not reach
+the C-locale fallbacks because in-tree callers special-case collate_is_c.
diff --git a/src/test/modules/test_pg_locale/expected/test_pg_locale.out b/src/test/modules/test_pg_locale/expected/test_pg_locale.out
new file mode 100644
index 00000000000..edbba284552
--- /dev/null
+++ b/src/test/modules/test_pg_locale/expected/test_pg_locale.out
@@ -0,0 +1,38 @@
+CREATE EXTENSION test_pg_locale;
+--
+-- These tests don't produce any interesting output. We're checking that
+-- the operations complete without crashing and that none of their internal
+-- sanity tests fail.
+--
+-- to_regcollation() returns NULL when the collation is absent or is not
+-- usable in the current database encoding. The function is STRICT, so
+-- those cases are skipped.
+--
+-- 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/modules/test_pg_locale/meson.build b/src/test/modules/test_pg_locale/meson.build
new file mode 100644
index 00000000000..f5097464ad2
--- /dev/null
+++ b/src/test/modules/test_pg_locale/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+test_pg_locale_sources = files(
+ 'test_pg_locale.c',
+)
+
+if host_system == 'windows'
+ test_pg_locale_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+ '--NAME', 'test_pg_locale',
+ '--FILEDESC', 'test_pg_locale - test code for pg_locale.h APIs',])
+endif
+
+test_pg_locale = shared_module('test_pg_locale',
+ test_pg_locale_sources,
+ kwargs: pg_test_mod_args,
+)
+test_install_libs += test_pg_locale
+
+test_install_data += files(
+ 'test_pg_locale.control',
+ 'test_pg_locale--1.0.sql',
+)
+
+tests += {
+ 'name': 'test_pg_locale',
+ 'sd': meson.current_source_dir(),
+ 'bd': meson.current_build_dir(),
+ 'regress': {
+ 'sql': [
+ 'test_pg_locale',
+ ],
+ },
+}
diff --git a/src/test/modules/test_pg_locale/sql/test_pg_locale.sql b/src/test/modules/test_pg_locale/sql/test_pg_locale.sql
new file mode 100644
index 00000000000..212012feb9c
--- /dev/null
+++ b/src/test/modules/test_pg_locale/sql/test_pg_locale.sql
@@ -0,0 +1,23 @@
+CREATE EXTENSION test_pg_locale;
+
+--
+-- These tests don't produce any interesting output. We're checking that
+-- the operations complete without crashing and that none of their internal
+-- sanity tests fail.
+--
+-- to_regcollation() returns NULL when the collation is absent or is not
+-- usable in the current database encoding. The function is STRICT, so
+-- those cases are skipped.
+--
+
+-- 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'));
diff --git a/src/test/modules/test_pg_locale/test_pg_locale--1.0.sql b/src/test/modules/test_pg_locale/test_pg_locale--1.0.sql
new file mode 100644
index 00000000000..134c4befa06
--- /dev/null
+++ b/src/test/modules/test_pg_locale/test_pg_locale--1.0.sql
@@ -0,0 +1,8 @@
+/* src/test/modules/test_pg_locale/test_pg_locale--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION test_pg_locale" to load this file. \quit
+
+CREATE FUNCTION test_pg_locale_apis(oid)
+ RETURNS pg_catalog.void
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
diff --git a/src/test/modules/test_pg_locale/test_pg_locale.c b/src/test/modules/test_pg_locale/test_pg_locale.c
new file mode 100644
index 00000000000..122152f5fcb
--- /dev/null
+++ b/src/test/modules/test_pg_locale/test_pg_locale.c
@@ -0,0 +1,153 @@
+/*-------------------------------------------------------------------------
+ *
+ * test_pg_locale.c
+ * Call pg_locale.h wrappers directly.
+ *
+ * SQL callers special-case collate_is_c, so the C-locale fallbacks are
+ * not reached by ordinary regression tests.
+ *
+ * Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ * src/test/modules/test_pg_locale/test_pg_locale.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "utils/pg_locale.h"
+
+PG_MODULE_MAGIC;
+
+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");
+ }
+}
+
+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/modules/test_pg_locale/test_pg_locale.control b/src/test/modules/test_pg_locale/test_pg_locale.control
new file mode 100644
index 00000000000..6b224d04a1b
--- /dev/null
+++ b/src/test/modules/test_pg_locale/test_pg_locale.control
@@ -0,0 +1,4 @@
+comment = 'Test code for pg_locale.h APIs'
+default_version = '1.0'
+module_pathname = '$libdir/test_pg_locale'
+relocatable = true
--
2.43.0
From 497a4685ddbcf2880a076604721a3e2b53453f5e Mon Sep 17 00:00:00 2001
From: Jeff Davis <[email protected]>
Date: Wed, 12 Aug 2026 07:32:19 -0700
Subject: [PATCH vPG20 1/3] Add missing comments in pg_locale.c.
Suggested-by: Andres Freund <[email protected]>
Discussion: https://postgr.es/m/v3nniwcrxejmcfvz56xbd22hphprqleuornd6hqkmw2bl7kgmz@cnytz2ee5ltk
Backpatch-through: 18
---
src/backend/utils/adt/pg_locale.c | 70 ++++++++++++++++++++++++++-----
1 file changed, 60 insertions(+), 10 deletions(-)
diff --git a/src/backend/utils/adt/pg_locale.c b/src/backend/utils/adt/pg_locale.c
index 9eb99487e57..da32590396f 100644
--- a/src/backend/utils/adt/pg_locale.c
+++ b/src/backend/utils/adt/pg_locale.c
@@ -1317,6 +1317,20 @@ strupper_c(char *dst, size_t dstsize, const char *src, size_t srclen)
return srclen;
}
+/*
+ * pg_strlower()
+ *
+ * Convert src to lowercase, and return the result length (not including
+ * terminating NUL).
+ *
+ * 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.
+ *
+ * If the result length is less than dstsize, the NUL-terminated result is
+ * stored in dst. Otherwise, the contents of dst are undefined, and the
+ * caller should use the return value to resize the buffer and retry.
+ */
size_t
pg_strlower(char *dst, size_t dstsize, const char *src, size_t srclen,
pg_locale_t locale)
@@ -1327,6 +1341,20 @@ pg_strlower(char *dst, size_t dstsize, const char *src, size_t srclen,
return locale->ctype->strlower(dst, dstsize, src, srclen, locale);
}
+/*
+ * pg_strtitle()
+ *
+ * Convert src to titlecase, and return the result length (not including
+ * terminating NUL).
+ *
+ * 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.
+ *
+ * If the result length is less than dstsize, the NUL-terminated result is
+ * stored in dst. Otherwise, the contents of dst are undefined, and the
+ * caller should use the return value to resize the buffer and retry.
+ */
size_t
pg_strtitle(char *dst, size_t dstsize, const char *src, size_t srclen,
pg_locale_t locale)
@@ -1337,6 +1365,20 @@ pg_strtitle(char *dst, size_t dstsize, const char *src, size_t srclen,
return locale->ctype->strtitle(dst, dstsize, src, srclen, locale);
}
+/*
+ * pg_strupper()
+ *
+ * Convert src to uppercase, and return the result length (not including
+ * terminating NUL).
+ *
+ * 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.
+ *
+ * If the result length is less than dstsize, the NUL-terminated result is
+ * stored in dst. Otherwise, the contents of dst are undefined, and the
+ * caller should use the return value to resize the buffer and retry.
+ */
size_t
pg_strupper(char *dst, size_t dstsize, const char *src, size_t srclen,
pg_locale_t locale)
@@ -1347,6 +1389,19 @@ pg_strupper(char *dst, size_t dstsize, const char *src, size_t srclen,
return locale->ctype->strupper(dst, dstsize, src, srclen, locale);
}
+/*
+ * pg_strfold()
+ *
+ * Casefold src, and return the result length (not including terminating NUL).
+ *
+ * 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.
+ *
+ * If the result length is less than dstsize, the NUL-terminated result is
+ * stored in dst. Otherwise, the contents of dst are undefined, and the
+ * caller should use the return value to resize the buffer and retry.
+ */
size_t
pg_strfold(char *dst, size_t dstsize, const char *src, size_t srclen,
pg_locale_t locale)
@@ -1392,11 +1447,9 @@ pg_strcoll(const char *arg1, const char *arg2, pg_locale_t locale)
/*
* pg_strncoll
*
- * Call ucol_strcollUTF8(), ucol_strcoll(), strcoll_l() or wcscoll_l() as
- * appropriate for the given locale, platform, and database encoding. If the
- * locale is not specified, use the database collation.
+ * Compare strings according to the given locale.
*
- * The input strings must be encoded in the database encoding.
+ * Strings must be encoded in the database encoding with no embedded NULs.
*
* The caller is responsible for breaking ties if the collation is
* deterministic; this maintains consistency with pg_strnxfrm(), which cannot
@@ -1412,9 +1465,6 @@ pg_strncoll(const char *arg1, size_t len1, const char *arg2, size_t len2,
/*
* Return true if the collation provider supports pg_strxfrm() and
* pg_strnxfrm(); otherwise false.
- *
- *
- * No similar problem is known for the ICU provider.
*/
bool
pg_strxfrm_enabled(pg_locale_t locale)
@@ -1445,8 +1495,8 @@ pg_strxfrm(char *dest, const char *src, size_t destsize, pg_locale_t locale)
* ordinary strcmp() on transformed strings is equivalent to pg_strcoll() on
* untransformed strings.
*
- * The input string must be encoded in the database encoding. If 'destsize' is
- * zero, 'dest' may be NULL.
+ * String must be encoded in the database encoding with no embedded NULs. If
+ * 'destsize' is zero, 'dest' may be NULL.
*
* Not all providers support pg_strnxfrm() safely. The caller should check
* pg_strxfrm_enabled() first, otherwise this function may return wrong
@@ -1492,7 +1542,7 @@ pg_strxfrm_prefix(char *dest, const char *src, size_t destsize,
* memcmp() on the byte sequence is equivalent to pg_strncoll() on
* untransformed strings. The result is not nul-terminated.
*
- * The input string must be encoded in the database encoding.
+ * String must be encoded in the database encoding with no embedded NULs.
*
* Not all providers support pg_strnxfrm_prefix() safely. The caller should
* check pg_strxfrm_prefix_enabled() first, otherwise this function may return
--
2.43.0
From ba30a89ef3c424b4436c91373001668daef068bf Mon Sep 17 00:00:00 2001
From: Jeff Davis <[email protected]>
Date: Wed, 12 Aug 2026 07:46:48 -0700
Subject: [PATCH vPG20 2/3] Ensure all pg_locale.h APIs work with collate_is_c.
Suggested-by: Andres Freund <[email protected]>
Discussion: https://postgr.es/m/v36ssaygf7grb3qzfsjhtdzi7kqd45ds56nyuf7gi5qjml4qbb@ezmfqzmhlrs2
Backpatch-through: 18
---
src/backend/utils/adt/pg_locale.c | 55 +++++++++++++++++++++++++++----
1 file changed, 49 insertions(+), 6 deletions(-)
diff --git a/src/backend/utils/adt/pg_locale.c b/src/backend/utils/adt/pg_locale.c
index da32590396f..2b501a7b6b7 100644
--- a/src/backend/utils/adt/pg_locale.c
+++ b/src/backend/utils/adt/pg_locale.c
@@ -1441,7 +1441,10 @@ pg_downcase_ident(char *dst, size_t dstsize, const char *src, size_t srclen)
int
pg_strcoll(const char *arg1, const char *arg2, pg_locale_t locale)
{
- return locale->collate->strcoll(arg1, arg2, locale);
+ if (locale->collate == NULL)
+ return strcmp(arg1, arg2);
+ else
+ return locale->collate->strcoll(arg1, arg2, locale);
}
/*
@@ -1459,7 +1462,16 @@ int
pg_strncoll(const char *arg1, size_t len1, const char *arg2, size_t len2,
pg_locale_t locale)
{
- return locale->collate->strncoll(arg1, len1, arg2, len2, locale);
+ if (locale->collate == NULL)
+ {
+ int result = memcmp(arg1, arg2, Min(len1, len2));
+
+ if ((result == 0) && (len1 != len2))
+ result = (len1 < len2) ? -1 : 1;
+ return result;
+ }
+ else
+ return locale->collate->strncoll(arg1, len1, arg2, len2, locale);
}
/*
@@ -1469,6 +1481,9 @@ pg_strncoll(const char *arg1, size_t len1, const char *arg2, size_t len2,
bool
pg_strxfrm_enabled(pg_locale_t locale)
{
+ if (locale->collate == NULL)
+ return true;
+
/*
* locale->collate->strnxfrm is still a required method, even if it may
* have the wrong behavior, because the planner uses it for estimates in
@@ -1485,7 +1500,10 @@ pg_strxfrm_enabled(pg_locale_t locale)
size_t
pg_strxfrm(char *dest, const char *src, size_t destsize, pg_locale_t locale)
{
- return locale->collate->strxfrm(dest, destsize, src, locale);
+ if (locale->collate == NULL)
+ return pg_strnxfrm(dest, destsize, src, strlen(src), locale);
+ else
+ return locale->collate->strxfrm(dest, destsize, src, locale);
}
/*
@@ -1510,6 +1528,16 @@ size_t
pg_strnxfrm(char *dest, size_t destsize, const char *src, size_t srclen,
pg_locale_t locale)
{
+ if (locale->collate == NULL)
+ {
+ if (destsize > srclen)
+ {
+ memcpy(dest, src, srclen);
+ dest[srclen] = '\0';
+ }
+
+ return srclen;
+ }
return locale->collate->strnxfrm(dest, destsize, src, srclen, locale);
}
@@ -1520,7 +1548,10 @@ pg_strnxfrm(char *dest, size_t destsize, const char *src, size_t srclen,
bool
pg_strxfrm_prefix_enabled(pg_locale_t locale)
{
- return (locale->collate->strnxfrm_prefix != NULL);
+ if (locale->collate == NULL)
+ return true;
+ else
+ return (locale->collate->strnxfrm_prefix != NULL);
}
/*
@@ -1532,7 +1563,10 @@ size_t
pg_strxfrm_prefix(char *dest, const char *src, size_t destsize,
pg_locale_t locale)
{
- return locale->collate->strxfrm_prefix(dest, destsize, src, locale);
+ if (locale->collate == NULL)
+ return pg_strnxfrm_prefix(dest, destsize, src, strlen(src), locale);
+ else
+ return locale->collate->strxfrm_prefix(dest, destsize, src, locale);
}
/*
@@ -1556,7 +1590,16 @@ size_t
pg_strnxfrm_prefix(char *dest, size_t destsize, const char *src,
size_t srclen, pg_locale_t locale)
{
- return locale->collate->strnxfrm_prefix(dest, destsize, src, srclen, locale);
+ if (locale->collate == NULL)
+ {
+ size_t len = Min(srclen, destsize);
+
+ if (destsize > 0)
+ memcpy(dest, src, len);
+ return len;
+ }
+ else
+ return locale->collate->strnxfrm_prefix(dest, destsize, src, srclen, locale);
}
bool
--
2.43.0
From 4b201e6dd10d59ea5b8d636f03b73e4d5ff4fdc2 Mon Sep 17 00:00:00 2001
From: Jeff Davis <[email protected]>
Date: Sat, 15 Aug 2026 12:26:09 -0700
Subject: [PATCH vPG20 3/3] Add C test module 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.
Discussion: https://postgr.es/m/v36ssaygf7grb3qzfsjhtdzi7kqd45ds56nyuf7gi5qjml4qbb@ezmfqzmhlrs2
Backpatch-through: 18
---
src/test/modules/Makefile | 1 +
src/test/modules/meson.build | 1 +
src/test/modules/test_pg_locale/.gitignore | 4 +
src/test/modules/test_pg_locale/Makefile | 23 +++
src/test/modules/test_pg_locale/README | 2 +
.../expected/test_pg_locale.out | 38 +++++
src/test/modules/test_pg_locale/meson.build | 33 ++++
.../test_pg_locale/sql/test_pg_locale.sql | 23 +++
.../test_pg_locale/test_pg_locale--1.0.sql | 8 +
.../modules/test_pg_locale/test_pg_locale.c | 153 ++++++++++++++++++
.../test_pg_locale/test_pg_locale.control | 4 +
11 files changed, 290 insertions(+)
create mode 100644 src/test/modules/test_pg_locale/.gitignore
create mode 100644 src/test/modules/test_pg_locale/Makefile
create mode 100644 src/test/modules/test_pg_locale/README
create mode 100644 src/test/modules/test_pg_locale/expected/test_pg_locale.out
create mode 100644 src/test/modules/test_pg_locale/meson.build
create mode 100644 src/test/modules/test_pg_locale/sql/test_pg_locale.sql
create mode 100644 src/test/modules/test_pg_locale/test_pg_locale--1.0.sql
create mode 100644 src/test/modules/test_pg_locale/test_pg_locale.c
create mode 100644 src/test/modules/test_pg_locale/test_pg_locale.control
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 098bb8142ae..8a2b09bd11e 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -41,6 +41,7 @@ SUBDIRS = \
test_oat_hooks \
test_parser \
test_pg_dump \
+ test_pg_locale \
test_plan_advice \
test_predtest \
test_radixtree \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4bca42bb370..71c4035b1b3 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -42,6 +42,7 @@ subdir('test_misc')
subdir('test_oat_hooks')
subdir('test_parser')
subdir('test_pg_dump')
+subdir('test_pg_locale')
subdir('test_plan_advice')
subdir('test_predtest')
subdir('test_radixtree')
diff --git a/src/test/modules/test_pg_locale/.gitignore b/src/test/modules/test_pg_locale/.gitignore
new file mode 100644
index 00000000000..5dcb3ff9723
--- /dev/null
+++ b/src/test/modules/test_pg_locale/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/test_pg_locale/Makefile b/src/test/modules/test_pg_locale/Makefile
new file mode 100644
index 00000000000..9b051f8a697
--- /dev/null
+++ b/src/test/modules/test_pg_locale/Makefile
@@ -0,0 +1,23 @@
+# src/test/modules/test_pg_locale/Makefile
+
+MODULE_big = test_pg_locale
+OBJS = \
+ $(WIN32RES) \
+ test_pg_locale.o
+PGFILEDESC = "test_pg_locale - test code for pg_locale.h APIs"
+
+EXTENSION = test_pg_locale
+DATA = test_pg_locale--1.0.sql
+
+REGRESS = test_pg_locale
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_pg_locale
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/test_pg_locale/README b/src/test/modules/test_pg_locale/README
new file mode 100644
index 00000000000..d95af97c005
--- /dev/null
+++ b/src/test/modules/test_pg_locale/README
@@ -0,0 +1,2 @@
+Calls pg_locale.h wrappers directly. Ordinary SQL tests do not reach
+the C-locale fallbacks because in-tree callers special-case collate_is_c.
diff --git a/src/test/modules/test_pg_locale/expected/test_pg_locale.out b/src/test/modules/test_pg_locale/expected/test_pg_locale.out
new file mode 100644
index 00000000000..edbba284552
--- /dev/null
+++ b/src/test/modules/test_pg_locale/expected/test_pg_locale.out
@@ -0,0 +1,38 @@
+CREATE EXTENSION test_pg_locale;
+--
+-- These tests don't produce any interesting output. We're checking that
+-- the operations complete without crashing and that none of their internal
+-- sanity tests fail.
+--
+-- to_regcollation() returns NULL when the collation is absent or is not
+-- usable in the current database encoding. The function is STRICT, so
+-- those cases are skipped.
+--
+-- 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/modules/test_pg_locale/meson.build b/src/test/modules/test_pg_locale/meson.build
new file mode 100644
index 00000000000..f5097464ad2
--- /dev/null
+++ b/src/test/modules/test_pg_locale/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+test_pg_locale_sources = files(
+ 'test_pg_locale.c',
+)
+
+if host_system == 'windows'
+ test_pg_locale_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+ '--NAME', 'test_pg_locale',
+ '--FILEDESC', 'test_pg_locale - test code for pg_locale.h APIs',])
+endif
+
+test_pg_locale = shared_module('test_pg_locale',
+ test_pg_locale_sources,
+ kwargs: pg_test_mod_args,
+)
+test_install_libs += test_pg_locale
+
+test_install_data += files(
+ 'test_pg_locale.control',
+ 'test_pg_locale--1.0.sql',
+)
+
+tests += {
+ 'name': 'test_pg_locale',
+ 'sd': meson.current_source_dir(),
+ 'bd': meson.current_build_dir(),
+ 'regress': {
+ 'sql': [
+ 'test_pg_locale',
+ ],
+ },
+}
diff --git a/src/test/modules/test_pg_locale/sql/test_pg_locale.sql b/src/test/modules/test_pg_locale/sql/test_pg_locale.sql
new file mode 100644
index 00000000000..212012feb9c
--- /dev/null
+++ b/src/test/modules/test_pg_locale/sql/test_pg_locale.sql
@@ -0,0 +1,23 @@
+CREATE EXTENSION test_pg_locale;
+
+--
+-- These tests don't produce any interesting output. We're checking that
+-- the operations complete without crashing and that none of their internal
+-- sanity tests fail.
+--
+-- to_regcollation() returns NULL when the collation is absent or is not
+-- usable in the current database encoding. The function is STRICT, so
+-- those cases are skipped.
+--
+
+-- 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'));
diff --git a/src/test/modules/test_pg_locale/test_pg_locale--1.0.sql b/src/test/modules/test_pg_locale/test_pg_locale--1.0.sql
new file mode 100644
index 00000000000..134c4befa06
--- /dev/null
+++ b/src/test/modules/test_pg_locale/test_pg_locale--1.0.sql
@@ -0,0 +1,8 @@
+/* src/test/modules/test_pg_locale/test_pg_locale--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION test_pg_locale" to load this file. \quit
+
+CREATE FUNCTION test_pg_locale_apis(oid)
+ RETURNS pg_catalog.void
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
diff --git a/src/test/modules/test_pg_locale/test_pg_locale.c b/src/test/modules/test_pg_locale/test_pg_locale.c
new file mode 100644
index 00000000000..122152f5fcb
--- /dev/null
+++ b/src/test/modules/test_pg_locale/test_pg_locale.c
@@ -0,0 +1,153 @@
+/*-------------------------------------------------------------------------
+ *
+ * test_pg_locale.c
+ * Call pg_locale.h wrappers directly.
+ *
+ * SQL callers special-case collate_is_c, so the C-locale fallbacks are
+ * not reached by ordinary regression tests.
+ *
+ * Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ * src/test/modules/test_pg_locale/test_pg_locale.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "utils/pg_locale.h"
+
+PG_MODULE_MAGIC;
+
+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");
+ }
+}
+
+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/modules/test_pg_locale/test_pg_locale.control b/src/test/modules/test_pg_locale/test_pg_locale.control
new file mode 100644
index 00000000000..6b224d04a1b
--- /dev/null
+++ b/src/test/modules/test_pg_locale/test_pg_locale.control
@@ -0,0 +1,4 @@
+comment = 'Test code for pg_locale.h APIs'
+default_version = '1.0'
+module_pathname = '$libdir/test_pg_locale'
+relocatable = true
--
2.43.0