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

Reply via email to