From d3b72b2b4b6a69545cfe33725c5c58063cfb85fc Mon Sep 17 00:00:00 2001
From: Jelte Fennema-Nio <postgres@jeltef.nl>
Date: Mon, 6 Jul 2026 22:08:02 +0200
Subject: [PATCH v2] Treat exact-match LIKE pattern as non-lossy in certain
 cases

---
 src/backend/utils/adt/like_support.c          | 109 +++++++++++++++---
 .../regress/expected/collate.icu.utf8.out     |  31 ++++-
 src/test/regress/expected/collate.out         |  31 ++++-
 src/test/regress/sql/collate.icu.utf8.sql     |  12 ++
 src/test/regress/sql/collate.sql              |  18 +++
 5 files changed, 180 insertions(+), 21 deletions(-)

diff --git a/src/backend/utils/adt/like_support.c b/src/backend/utils/adt/like_support.c
index 4c8db9147ee..c396b4f4724 100644
--- a/src/backend/utils/adt/like_support.c
+++ b/src/backend/utils/adt/like_support.c
@@ -66,7 +66,10 @@ typedef enum
 
 typedef enum
 {
-	Pattern_Prefix_None, Pattern_Prefix_Partial, Pattern_Prefix_Exact,
+	Pattern_Prefix_None,		/* cannot produce an indexqual */
+	Pattern_Prefix_Partial,		/* maps to an index range restriction */
+	Pattern_Prefix_Exact,		/* maps to a simple equality test */
+	Pattern_Prefix_Exact_Lossy, /* maps to an equality test, but recheck */
 } Pattern_Prefix_Status;
 
 /* non-collatable comparisons, eg for bytea, are always deterministic */
@@ -79,7 +82,8 @@ static List *match_pattern_prefix(Node *leftop,
 								  Pattern_Type ptype,
 								  Oid expr_coll,
 								  Oid opfamily,
-								  Oid indexcollation);
+								  Oid indexcollation,
+								  bool *lossy);
 static double patternsel_common(PlannerInfo *root,
 								Oid oprid,
 								Oid opfuncid,
@@ -215,7 +219,8 @@ like_regex_support(Node *rawreq, Pattern_Type ptype)
 									 ptype,
 									 clause->inputcollid,
 									 req->opfamily,
-									 req->indexcollation);
+									 req->indexcollation,
+									 &req->lossy);
 		}
 		else if (is_funcclause(req->node))	/* be paranoid */
 		{
@@ -228,7 +233,8 @@ like_regex_support(Node *rawreq, Pattern_Type ptype)
 									 ptype,
 									 clause->inputcollid,
 									 req->opfamily,
-									 req->indexcollation);
+									 req->indexcollation,
+									 &req->lossy);
 		}
 	}
 
@@ -238,6 +244,10 @@ like_regex_support(Node *rawreq, Pattern_Type ptype)
 /*
  * match_pattern_prefix
  *	  Try to generate an indexqual for a LIKE or regex operator.
+ *
+ * We return a list of indexqual expressions on success, or NIL on failure.
+ * On success, *lossy is changed to false (from its initial state of true)
+ * if the indexqual expression(s) can fully replace the operator.
  */
 static List *
 match_pattern_prefix(Node *leftop,
@@ -245,7 +255,8 @@ match_pattern_prefix(Node *leftop,
 					 Pattern_Type ptype,
 					 Oid expr_coll,
 					 Oid opfamily,
-					 Oid indexcollation)
+					 Oid indexcollation,
+					 bool *lossy)
 {
 	List	   *result;
 	Const	   *patt;
@@ -396,7 +407,8 @@ match_pattern_prefix(Node *leftop,
 	 * apply the LIKE/regex operator as a recheck, and that will filter out
 	 * any non-matching entries.
 	 */
-	if (pstatus == Pattern_Prefix_Exact)
+	if (pstatus == Pattern_Prefix_Exact ||
+		pstatus == Pattern_Prefix_Exact_Lossy)
 	{
 		if (!op_in_opfamily(eqopr, opfamily))
 			return NIL;
@@ -405,14 +417,51 @@ match_pattern_prefix(Node *leftop,
 		expr = make_opclause(eqopr, BOOLOID, false,
 							 (Expr *) leftop, (Expr *) prefix,
 							 InvalidOid, indexcollation);
+
+		/*
+		 * We can even drop the recheck (mark the indexqual non-lossy) when
+		 * "=" means exactly what the exact-match pattern means.  That
+		 * requires all of the following:
+		 *
+		 * - The pattern is Pattern_Prefix_Exact.
+		 *
+		 * - The left-hand type is not bpchar.  bpchar's "=" ignores trailing
+		 * blanks while LIKE does not, so "= 'abc'" matches a stored 'abc  '
+		 * that "LIKE 'abc'" rejects; the recheck is what removes it.
+		 *
+		 * - The index and expression collations agree on equality.  That
+		 * holds when they're the same collation, or (having passed the guard
+		 * above, which rejected a nondeterministic expression collation that
+		 * differs from the index's) when the index collation is
+		 * deterministic: a deterministic collation treats two strings as
+		 * equal iff they're bytewise equal, so it can't disagree with the
+		 * expression's equality. A nondeterministic index collation that
+		 * differs from the expression's makes "=" match a superset of the
+		 * pattern, so the recheck stays.
+		 *
+		 * - The expression actually has a collation whenever the index does.
+		 * A collatable expression with an indeterminate collation (e.g. a
+		 * concatenation of differently-collated columns) carries InvalidOid,
+		 * and the pattern match is supposed to fail at runtime with "could
+		 * not determine which collation to use".  Dropping the recheck would
+		 * instead let the "=" indexqual silently resolve the comparison under
+		 * the index's collation.  A genuinely non-collatable type (bytea)
+		 * also carries InvalidOid, but then the index collation is InvalidOid
+		 * too, so indexcollation == expr_coll and we still optimize.
+		 */
+		if (pstatus == Pattern_Prefix_Exact &&
+			ldatatype != BPCHAROID &&
+			(indexcollation == expr_coll ||
+			 (OidIsValid(expr_coll) && !NONDETERMINISTIC(indexcollation))))
+			*lossy = false;
 		result = list_make1(expr);
 		return result;
 	}
 
 	/*
-	 * Anything other than Pattern_Prefix_Exact is not supported if the
-	 * expression collation is nondeterministic.  The optimized equality or
-	 * prefix tests use bytewise comparisons, which is not consistent with
+	 * Anything other than Pattern_Prefix_Exact[_Lossy] is not supported if
+	 * the expression collation is nondeterministic.  The optimized equality
+	 * or prefix tests use bytewise comparisons, which is not consistent with
 	 * nondeterministic collations.
 	 */
 	if (NONDETERMINISTIC(expr_coll))
@@ -645,7 +694,8 @@ patternsel_common(PlannerInfo *root,
 		prefix->consttype = rdatatype;
 	}
 
-	if (pstatus == Pattern_Prefix_Exact)
+	if (pstatus == Pattern_Prefix_Exact ||
+		pstatus == Pattern_Prefix_Exact_Lossy)
 	{
 		/*
 		 * Pattern specifies an exact match, so estimate as for '='
@@ -985,7 +1035,7 @@ icnlikejoinsel(PG_FUNCTION_ARGS)
 /*
  * Extract the fixed prefix, if any, for a pattern.
  *
- * *prefix is set to a palloc'd prefix string (in the form of a Const node),
+ * *prefix_const is set to a palloc'd string (in the form of a Const node),
  *	or to NULL if no fixed prefix exists for the pattern.
  * If rest_selec is not NULL, *rest_selec is set to an estimate of the
  *	selectivity of the remainder of the pattern (without any fixed prefix).
@@ -1005,6 +1055,7 @@ like_fixed_prefix(Const *patt_const, Const **prefix_const,
 	Oid			typeid = patt_const->consttype;
 	int			pos,
 				match_pos;
+	bool		badpattern = false;
 
 	/* the right-hand const is type text or bytea */
 	Assert(typeid == BYTEAOID || typeid == TEXTOID);
@@ -1038,7 +1089,10 @@ like_fixed_prefix(Const *patt_const, Const **prefix_const,
 		{
 			pos++;
 			if (pos >= pattlen)
+			{
+				badpattern = true;
 				break;
+			}
 		}
 
 		match[match_pos++] = patt[pos];
@@ -1059,7 +1113,16 @@ like_fixed_prefix(Const *patt_const, Const **prefix_const,
 
 	/* in LIKE, an empty pattern is an exact match! */
 	if (pos == pattlen)
-		return Pattern_Prefix_Exact;	/* reached end of pattern, so exact */
+	{
+		/*
+		 * Reached end of pattern, so exact -- unless the operator would throw
+		 * an error at runtime.  In that case our representation is lossy,
+		 * since we don't want to throw that error here.
+		 */
+		if (badpattern)
+			return Pattern_Prefix_Exact_Lossy;
+		return Pattern_Prefix_Exact;
+	}
 
 	if (match_pos > 0)
 		return Pattern_Prefix_Partial;
@@ -1086,6 +1149,7 @@ like_fixed_prefix_ci(Const *patt_const, Oid collation, Const **prefix_const,
 	char	   *match;
 	int			match_mblen;
 	pg_locale_t locale = 0;
+	bool		badpattern = false;
 
 	/* the right-hand const is type text or bytea */
 	Assert(typeid == BYTEAOID || typeid == TEXTOID);
@@ -1125,7 +1189,10 @@ like_fixed_prefix_ci(Const *patt_const, Oid collation, Const **prefix_const,
 		{
 			wpos++;
 			if (wpos >= wpattlen)
+			{
+				badpattern = true;
 				break;
+			}
 		}
 
 		/*
@@ -1165,7 +1232,16 @@ like_fixed_prefix_ci(Const *patt_const, Oid collation, Const **prefix_const,
 
 	/* in LIKE, an empty pattern is an exact match! */
 	if (wpos == wpattlen)
-		return Pattern_Prefix_Exact;	/* reached end of pattern, so exact */
+	{
+		/*
+		 * Reached end of pattern, so exact -- unless the operator would throw
+		 * an error at runtime.  In that case our representation is lossy,
+		 * since we don't want to throw that error here.
+		 */
+		if (badpattern)
+			return Pattern_Prefix_Exact_Lossy;
+		return Pattern_Prefix_Exact;
+	}
 
 	if (wmatch_pos > 0)
 		return Pattern_Prefix_Partial;
@@ -1235,8 +1311,13 @@ regex_fixed_prefix(Const *patt_const, bool case_insensitive, Oid collation,
 
 	pfree(prefix);
 
+	/*
+	 * Because regexp_fixed_prefix() doesn't analyze the regexp fully, even an
+	 * "exact" match is lossy: we know that a match must be this string, but
+	 * it could still fail to match.
+	 */
 	if (exact)
-		return Pattern_Prefix_Exact;	/* pattern specifies exact match */
+		return Pattern_Prefix_Exact_Lossy;
 	else
 		return Pattern_Prefix_Partial;
 }
diff --git a/src/test/regress/expected/collate.icu.utf8.out b/src/test/regress/expected/collate.icu.utf8.out
index fcfcc658bea..3b5232df5de 100644
--- a/src/test/regress/expected/collate.icu.utf8.out
+++ b/src/test/regress/expected/collate.icu.utf8.out
@@ -2128,12 +2128,11 @@ SELECT * FROM test1ci WHERE x ~ '^abc$' COLLATE "C";
 
 EXPLAIN (COSTS OFF)
 SELECT * FROM test1ci WHERE x LIKE 'abc' COLLATE case_insensitive;
-                      QUERY PLAN                       
--------------------------------------------------------
+                QUERY PLAN                 
+-------------------------------------------
  Index Scan using test1ci_x_idx on test1ci
    Index Cond: (x = 'abc'::text)
-   Filter: (x ~~ 'abc'::text COLLATE case_insensitive)
-(3 rows)
+(2 rows)
 
 RESET enable_seqscan;
 RESET enable_indexonlyscan;
@@ -2799,6 +2798,30 @@ SELECT x FROM test4c WHERE x LIKE 'ABC%' COLLATE case_insensitive;  -- ok
  abc
 (1 row)
 
+RESET enable_seqscan;
+-- For text (test4c above) an exact-match LIKE with the index and expression
+-- sharing the collation is non-lossy, so the recheck is dropped.  bpchar is
+-- different: its "=" ignores trailing blanks while LIKE does not, so the
+-- recheck must stay to reject a padded 'abc  ' that "=" would treat as equal
+-- to 'abc'.  The Index Cond therefore still carries a "~~" Filter here.
+SET enable_seqscan = off;
+EXPLAIN (COSTS OFF)
+SELECT x FROM test4c WHERE x LIKE 'abc' COLLATE case_insensitive;
+                  QUERY PLAN                  
+----------------------------------------------
+ Index Only Scan using test4c_x_idx on test4c
+   Index Cond: (x = 'abc'::text)
+(2 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT x FROM test1bpci WHERE x LIKE 'abc' COLLATE case_insensitive;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Index Only Scan using test1bpci_x_idx on test1bpci
+   Index Cond: (x = 'abc'::bpchar)
+   Filter: (x ~~ 'abc'::text COLLATE case_insensitive)
+(3 rows)
+
 RESET enable_seqscan;
 -- Unicode special case: different variants of Greek lower case sigma.
 -- A naive implementation like citext that just does lower(x) =
diff --git a/src/test/regress/expected/collate.out b/src/test/regress/expected/collate.out
index b17c5abaddc..77616fd297b 100644
--- a/src/test/regress/expected/collate.out
+++ b/src/test/regress/expected/collate.out
@@ -784,13 +784,38 @@ SELECT * FROM pg_class WHERE relname ~ '^pg_class$' COLLATE "POSIX";
 
 EXPLAIN (COSTS OFF)
 SELECT * FROM pg_class WHERE relname LIKE 'pg\_class' COLLATE "POSIX";
-                        QUERY PLAN                        
-----------------------------------------------------------
+                       QUERY PLAN                        
+---------------------------------------------------------
  Index Scan using pg_class_relname_nsp_index on pg_class
    Index Cond: (relname = 'pg_class'::text)
-   Filter: (relname ~~ 'pg\_class'::text COLLATE "POSIX")
+(2 rows)
+
+-- Conversely, when the expression's collation is indeterminate (here a
+-- concatenation of a "C" and a "POSIX" column) but the index pins a collation,
+-- the exact-match "=" indexqual must stay lossy so the LIKE recheck still runs.
+-- Otherwise the query would silently resolve the comparison under the index's
+-- collation instead of failing with "could not determine which collation to
+-- use", making the result depend on whether an index scan is chosen.
+CREATE INDEX collate_test10_expr_idx ON collate_test10 ((x || y) COLLATE "C");
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT a FROM collate_test10 WHERE (x || y) LIKE 'hijhij';
+                         QUERY PLAN                         
+------------------------------------------------------------
+ Index Scan using collate_test10_expr_idx on collate_test10
+   Index Cond: ((x || y) = 'hijhij'::text)
+   Filter: ((x || y) ~~ 'hijhij'::text)
 (3 rows)
 
+-- must error (rather than silently return a row via the index) so that the
+-- outcome does not depend on the chosen plan
+SELECT a FROM collate_test10 WHERE (x || y) LIKE 'hijhij';
+ERROR:  could not determine which collation to use for LIKE
+HINT:  Use the COLLATE clause to set the collation explicitly.
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP INDEX collate_test10_expr_idx;
 --
 -- Clean up.  Many of these table names will be re-used if the user is
 -- trying to run any platform-specific collation tests later, so we
diff --git a/src/test/regress/sql/collate.icu.utf8.sql b/src/test/regress/sql/collate.icu.utf8.sql
index ce4e2bb3ffd..2660b1cb42d 100644
--- a/src/test/regress/sql/collate.icu.utf8.sql
+++ b/src/test/regress/sql/collate.icu.utf8.sql
@@ -999,6 +999,18 @@ SELECT x FROM test4c WHERE x LIKE 'ABC' COLLATE case_insensitive;  -- ok
 SELECT x FROM test4c WHERE x LIKE 'ABC%' COLLATE case_insensitive;  -- ok
 RESET enable_seqscan;
 
+-- For text (test4c above) an exact-match LIKE with the index and expression
+-- sharing the collation is non-lossy, so the recheck is dropped.  bpchar is
+-- different: its "=" ignores trailing blanks while LIKE does not, so the
+-- recheck must stay to reject a padded 'abc  ' that "=" would treat as equal
+-- to 'abc'.  The Index Cond therefore still carries a "~~" Filter here.
+SET enable_seqscan = off;
+EXPLAIN (COSTS OFF)
+SELECT x FROM test4c WHERE x LIKE 'abc' COLLATE case_insensitive;
+EXPLAIN (COSTS OFF)
+SELECT x FROM test1bpci WHERE x LIKE 'abc' COLLATE case_insensitive;
+RESET enable_seqscan;
+
 -- Unicode special case: different variants of Greek lower case sigma.
 -- A naive implementation like citext that just does lower(x) =
 -- lower(y) will do the wrong thing here, because lower('Σ') is 'σ'
diff --git a/src/test/regress/sql/collate.sql b/src/test/regress/sql/collate.sql
index b018da13f24..791e6af0a71 100644
--- a/src/test/regress/sql/collate.sql
+++ b/src/test/regress/sql/collate.sql
@@ -312,6 +312,24 @@ SELECT * FROM pg_class WHERE relname ~ '^pg_class$' COLLATE "POSIX";
 EXPLAIN (COSTS OFF)
 SELECT * FROM pg_class WHERE relname LIKE 'pg\_class' COLLATE "POSIX";
 
+-- Conversely, when the expression's collation is indeterminate (here a
+-- concatenation of a "C" and a "POSIX" column) but the index pins a collation,
+-- the exact-match "=" indexqual must stay lossy so the LIKE recheck still runs.
+-- Otherwise the query would silently resolve the comparison under the index's
+-- collation instead of failing with "could not determine which collation to
+-- use", making the result depend on whether an index scan is chosen.
+CREATE INDEX collate_test10_expr_idx ON collate_test10 ((x || y) COLLATE "C");
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT a FROM collate_test10 WHERE (x || y) LIKE 'hijhij';
+-- must error (rather than silently return a row via the index) so that the
+-- outcome does not depend on the chosen plan
+SELECT a FROM collate_test10 WHERE (x || y) LIKE 'hijhij';
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP INDEX collate_test10_expr_idx;
+
 --
 -- Clean up.  Many of these table names will be re-used if the user is
 -- trying to run any platform-specific collation tests later, so we

base-commit: 4c84545067822bcc8697b7d8f3082c5cf1937d1b
-- 
2.54.0

