On Wed, 2026-08-26 at 15:15 -0700, Noah Misch wrote:
> Opus 5 found the $SUBJECT regression from commit 630706c "Add
> pg_iswcased()".
> That finding still holds at today's master. I am attaching the LLM's
> report
> and test case.
Thank you for the report. Patch attached.
I can add a test or two, but they'd be fairly heavyweight and platform-
specific, I think.
Regards,
Jeff Davis
From 2a7fb4373feb984bb0d9b8bbbe5a2f7e3ebc0489 Mon Sep 17 00:00:00 2001
From: Jeff Davis <[email protected]>
Date: Tue, 1 Sep 2026 12:26:29 -0700
Subject: [PATCH v1] Fix pg_iswcased().
Commit 630706ced0 and 9c8de15969 lost some encoding-specific nuances:
in libc with EUC encodings, or in ICU with any non-UTF8 encoding, we
cannot easily classify characters, so we must assume they are
case-varying. Restore these behaviors.
Reported-by: Noah Misch <[email protected]>
Discussion: https://postgr.es/m/[email protected]
Backpatch-through: 19
---
src/backend/utils/adt/pg_locale.c | 5 ++++
src/backend/utils/adt/pg_locale_icu.c | 16 +++++++++++-
src/backend/utils/adt/pg_locale_libc.c | 36 +++++++++++++++++++++++---
3 files changed, 52 insertions(+), 5 deletions(-)
diff --git a/src/backend/utils/adt/pg_locale.c b/src/backend/utils/adt/pg_locale.c
index 3a79203dbc3..06a8509a284 100644
--- a/src/backend/utils/adt/pg_locale.c
+++ b/src/backend/utils/adt/pg_locale.c
@@ -1771,6 +1771,11 @@ pg_iswxdigit(pg_wchar wc, pg_locale_t locale)
return locale->ctype->wc_isxdigit(wc, locale);
}
+/*
+ * Is the character potentially case-varying? Used by ILIKE to extract a
+ * prefix suitable for an index search. Safe to return true if the character
+ * can't be easily classified.
+ */
bool
pg_iswcased(pg_wchar wc, pg_locale_t locale)
{
diff --git a/src/backend/utils/adt/pg_locale_icu.c b/src/backend/utils/adt/pg_locale_icu.c
index 9d7f0dd3eb5..3a1dc41290b 100644
--- a/src/backend/utils/adt/pg_locale_icu.c
+++ b/src/backend/utils/adt/pg_locale_icu.c
@@ -238,6 +238,20 @@ wc_isxdigit_icu(pg_wchar wc, pg_locale_t locale)
static bool
wc_iscased_icu(pg_wchar wc, pg_locale_t locale)
+{
+ /*
+ * For non-UTF8 encodings (single or multibyte), pg_wchar may not be a
+ * codepoint, so we conservatively assume that any non-ASCII character
+ * could be case-varying.
+ */
+ if (wc > (pg_wchar) 127)
+ return true;
+
+ return u_hasBinaryProperty(wc, UCHAR_CASED);
+}
+
+static bool
+wc_iscased_icu_utf8(pg_wchar wc, pg_locale_t locale)
{
return u_hasBinaryProperty(wc, UCHAR_CASED);
}
@@ -280,7 +294,7 @@ static const struct ctype_methods ctype_methods_icu_utf8 = {
.wc_ispunct = wc_ispunct_icu,
.wc_isspace = wc_isspace_icu,
.wc_isxdigit = wc_isxdigit_icu,
- .wc_iscased = wc_iscased_icu,
+ .wc_iscased = wc_iscased_icu_utf8,
.wc_toupper = toupper_icu,
.wc_tolower = tolower_icu,
};
diff --git a/src/backend/utils/adt/pg_locale_libc.c b/src/backend/utils/adt/pg_locale_libc.c
index d9a33db8de5..7348ed90fc3 100644
--- a/src/backend/utils/adt/pg_locale_libc.c
+++ b/src/backend/utils/adt/pg_locale_libc.c
@@ -299,10 +299,37 @@ wc_isxdigit_libc_mb(pg_wchar wc, pg_locale_t locale)
}
static bool
-wc_iscased_libc_mb(pg_wchar wc, pg_locale_t locale)
+wc_iscased_libc_other_mb(pg_wchar wc, pg_locale_t locale)
{
+ /*
+ * For non-UTF8 multibyte encodings, we conservatively assume that any
+ * non-ASCII character could be case-varying.
+ */
+ if (wc > (pg_wchar) 127)
+ return true;
+
+ /* ASCII: pass directly to isupper_l()/islower_l() */
+ return isupper_l((unsigned char) wc, locale->lt) ||
+ islower_l((unsigned char) wc, locale->lt);
+}
+
+static bool
+wc_iscased_libc_utf8(pg_wchar wc, pg_locale_t locale)
+{
+ /*
+ * If sizeof(wchar_t) < 4 (that is, on Windows), then return false. This
+ * is consistent with the behavior of strlower_libc_mb(): the UTF8 string
+ * will be decoded into 16-bit wchar_t, so strlower_libc_mb() will never
+ * deal with codepoints beyond 0xFFFF. It may deal with surrogate pairs,
+ * but those characters map to themselves anyway.
+ */
if (sizeof(wchar_t) < 4 && wc > (pg_wchar) 0xFFFF)
return false;
+
+ /*
+ * For UTF8, pg_wchar is a codepoint and we assume we can pass it directly
+ * to iswupper_l()/iswlower_l().
+ */
return iswupper_l((wint_t) wc, locale->lt) ||
iswlower_l((wint_t) wc, locale->lt);
}
@@ -415,7 +442,8 @@ static const struct ctype_methods ctype_methods_libc_sb = {
/*
* Non-UTF8 multibyte encodings use multibyte semantics for case mapping, but
- * single-byte semantics for pattern matching.
+ * single-byte semantics for pattern matching (except wc_iscased which needs
+ * to be consistent with case mapping).
*/
static const struct ctype_methods ctype_methods_libc_other_mb = {
.strlower = strlower_libc_mb,
@@ -435,7 +463,7 @@ static const struct ctype_methods ctype_methods_libc_other_mb = {
.wc_ispunct = wc_ispunct_libc_sb,
.wc_isspace = wc_isspace_libc_sb,
.wc_isxdigit = wc_isxdigit_libc_sb,
- .wc_iscased = wc_iscased_libc_sb,
+ .wc_iscased = wc_iscased_libc_other_mb,
.wc_toupper = toupper_libc_sb,
.wc_tolower = tolower_libc_sb,
};
@@ -458,7 +486,7 @@ static const struct ctype_methods ctype_methods_libc_utf8 = {
.wc_ispunct = wc_ispunct_libc_mb,
.wc_isspace = wc_isspace_libc_mb,
.wc_isxdigit = wc_isxdigit_libc_mb,
- .wc_iscased = wc_iscased_libc_mb,
+ .wc_iscased = wc_iscased_libc_utf8,
.wc_toupper = toupper_libc_mb,
.wc_tolower = tolower_libc_mb,
};
--
2.43.0