From 0a4ff9e4b77591bfd7bf084897e5d1d302139bf4 Mon Sep 17 00:00:00 2001
From: Vaibhav Dalvi <vaibhav.dalvi@enterprisedb.com>
Date: Wed, 2 Sep 2026 10:41:59 +0000
Subject: [PATCH] Add gist_trgm_ops_noeq, a gist_trgm_ops without '='

gist_trgm_ops's '=' support can lead the planner to pick a GiST index
over a much cheaper btree index for a plain equality lookup, because
the generic, selectivity-based cost estimate doesn't reflect how that
operator is actually checked (a superset-of-trigrams test that can be
a false positive regardless of how selective the value is).

Rather than changing gist_trgm_ops itself, or the planner's costing,
this adds a second, independently-named opclass that is otherwise
identical to gist_trgm_ops, minus operator 11 ('='). Existing
gist_trgm_ops indexes are completely unaffected. Anyone who wants the
extra safety can create new indexes with gist_trgm_ops_noeq, or
rebuild an existing index onto it, and the planner will then simply
never have '=' available on that index at all.

No core or C code changes; this is pure SQL/DDL, so there's no new
cost-model behavior to reason about or get wrong. Tested that '='
cannot use the new opclass under any planner settings, and that the
other operators still return correct results through it. A new
regression test covers both.

Discussion: https://postgr.es/m/CAOBaU_YWwtT7tdggtROacjdOdeYHCz-tmSwuC-j-TOG-g97J0w@mail.gmail.com
---
 contrib/pg_trgm/Makefile                  |  8 ++--
 contrib/pg_trgm/expected/pg_trgm_noeq.out | 53 +++++++++++++++++++++++
 contrib/pg_trgm/pg_trgm--1.6--1.7.sql     | 31 +++++++++++++
 contrib/pg_trgm/pg_trgm.control           |  2 +-
 contrib/pg_trgm/sql/pg_trgm_noeq.sql      | 30 +++++++++++++
 doc/src/sgml/pgtrgm.sgml                  |  8 ++++
 6 files changed, 127 insertions(+), 5 deletions(-)
 create mode 100644 contrib/pg_trgm/expected/pg_trgm_noeq.out
 create mode 100644 contrib/pg_trgm/pg_trgm--1.6--1.7.sql
 create mode 100644 contrib/pg_trgm/sql/pg_trgm_noeq.sql

diff --git a/contrib/pg_trgm/Makefile b/contrib/pg_trgm/Makefile
index c1756993ec7..cd19ea5c396 100644
--- a/contrib/pg_trgm/Makefile
+++ b/contrib/pg_trgm/Makefile
@@ -9,12 +9,12 @@ OBJS = \
 	trgm_regexp.o
 
 EXTENSION = pg_trgm
-DATA = pg_trgm--1.5--1.6.sql pg_trgm--1.4--1.5.sql pg_trgm--1.3--1.4.sql \
-	pg_trgm--1.3.sql pg_trgm--1.2--1.3.sql pg_trgm--1.1--1.2.sql \
-	pg_trgm--1.0--1.1.sql
+DATA = pg_trgm--1.6--1.7.sql pg_trgm--1.5--1.6.sql pg_trgm--1.4--1.5.sql \
+	pg_trgm--1.3--1.4.sql pg_trgm--1.3.sql pg_trgm--1.2--1.3.sql \
+	pg_trgm--1.1--1.2.sql pg_trgm--1.0--1.1.sql
 PGFILEDESC = "pg_trgm - trigram matching"
 
-REGRESS = pg_trgm pg_utf8_trgm pg_word_trgm pg_strict_word_trgm
+REGRESS = pg_trgm pg_utf8_trgm pg_word_trgm pg_strict_word_trgm pg_trgm_noeq
 
 ifdef USE_PGXS
 PG_CONFIG = pg_config
diff --git a/contrib/pg_trgm/expected/pg_trgm_noeq.out b/contrib/pg_trgm/expected/pg_trgm_noeq.out
new file mode 100644
index 00000000000..89072ea4721
--- /dev/null
+++ b/contrib/pg_trgm/expected/pg_trgm_noeq.out
@@ -0,0 +1,53 @@
+-- Tests for the gist_trgm_ops_noeq operator class.
+-- Should validate cleanly, same as any other opclass.
+SELECT amname, opcname
+FROM pg_opclass opc LEFT JOIN pg_am am ON am.oid = opcmethod
+WHERE opcname = 'gist_trgm_ops_noeq' AND NOT amvalidate(opc.oid);
+ amname | opcname 
+--------+---------
+(0 rows)
+
+create table noeq_test (a text);
+insert into noeq_test select md5(g::text) from generate_series(1, 100) g;
+create index noeq_test_gist on noeq_test using gist (a gist_trgm_ops_noeq);
+-- The opclass doesn't offer '=' at all, so it must never be used for it,
+-- even with every alternative disabled.
+set enable_seqscan = off;
+set enable_bitmapscan = off;
+set enable_indexonlyscan = off;
+explain (costs off)
+  select * from noeq_test where a = '1234';
+          QUERY PLAN          
+------------------------------
+ Seq Scan on noeq_test
+   Disabled: true
+   Filter: (a = '1234'::text)
+(3 rows)
+
+-- Other operators still work, and give correct results.
+reset enable_seqscan;
+reset enable_bitmapscan;
+select count(*) from noeq_test where a like '%ab%';
+ count 
+-------
+    16
+(1 row)
+
+set enable_seqscan = off;
+explain (costs off)
+  select count(*) from noeq_test where a like '%ab%';
+                     QUERY PLAN                     
+----------------------------------------------------
+ Aggregate
+   ->  Index Scan using noeq_test_gist on noeq_test
+         Index Cond: (a ~~ '%ab%'::text)
+(3 rows)
+
+select count(*) from noeq_test where a like '%ab%';
+ count 
+-------
+    16
+(1 row)
+
+reset enable_seqscan;
+drop table noeq_test;
diff --git a/contrib/pg_trgm/pg_trgm--1.6--1.7.sql b/contrib/pg_trgm/pg_trgm--1.6--1.7.sql
new file mode 100644
index 00000000000..7cbe2a1ae17
--- /dev/null
+++ b/contrib/pg_trgm/pg_trgm--1.6--1.7.sql
@@ -0,0 +1,31 @@
+/* contrib/pg_trgm/pg_trgm--1.6--1.7.sql */
+
+-- complain if script is sourced in psql, rather than via ALTER EXTENSION
+\echo Use "ALTER EXTENSION pg_trgm UPDATE TO '1.7'" to load this file. \quit
+
+-- A GiST opclass identical to gist_trgm_ops, minus the '=' operator.
+CREATE OPERATOR FAMILY gist_trgm_ops_noeq USING gist;
+
+CREATE OPERATOR CLASS gist_trgm_ops_noeq
+FOR TYPE text USING gist FAMILY gist_trgm_ops_noeq AS
+	OPERATOR	1	%  (text, text),
+	OPERATOR	2	<-> (text, text) FOR ORDER BY pg_catalog.float_ops,
+	OPERATOR	3	pg_catalog.~~ (text, text),
+	OPERATOR	4	pg_catalog.~~* (text, text),
+	OPERATOR	5	pg_catalog.~ (text, text),
+	OPERATOR	6	pg_catalog.~* (text, text),
+	OPERATOR	7	%> (text, text),
+	OPERATOR	8	<->> (text, text) FOR ORDER BY pg_catalog.float_ops,
+	OPERATOR	9	%>> (text, text),
+	OPERATOR	10	<->>> (text, text) FOR ORDER BY pg_catalog.float_ops,
+	-- deliberately no OPERATOR 11 ( pg_catalog.= )
+	FUNCTION	1	gtrgm_consistent (internal, text, smallint, oid, internal),
+	FUNCTION	2	gtrgm_union (internal, internal),
+	FUNCTION	3	gtrgm_compress (internal),
+	FUNCTION	4	gtrgm_decompress (internal),
+	FUNCTION	5	gtrgm_penalty (internal, internal, internal),
+	FUNCTION	6	gtrgm_picksplit (internal, internal),
+	FUNCTION	7	gtrgm_same (gtrgm, gtrgm, internal),
+	FUNCTION	8	gtrgm_distance (internal, text, smallint, oid, internal),
+	FUNCTION	10	gtrgm_options (internal),
+	STORAGE		gtrgm;
diff --git a/contrib/pg_trgm/pg_trgm.control b/contrib/pg_trgm/pg_trgm.control
index 1d6a9ddf259..6e3ee43c510 100644
--- a/contrib/pg_trgm/pg_trgm.control
+++ b/contrib/pg_trgm/pg_trgm.control
@@ -1,6 +1,6 @@
 # pg_trgm extension
 comment = 'text similarity measurement and index searching based on trigrams'
-default_version = '1.6'
+default_version = '1.7'
 module_pathname = '$libdir/pg_trgm'
 relocatable = true
 trusted = true
diff --git a/contrib/pg_trgm/sql/pg_trgm_noeq.sql b/contrib/pg_trgm/sql/pg_trgm_noeq.sql
new file mode 100644
index 00000000000..9bd777cacc3
--- /dev/null
+++ b/contrib/pg_trgm/sql/pg_trgm_noeq.sql
@@ -0,0 +1,30 @@
+-- Tests for the gist_trgm_ops_noeq operator class.
+
+-- Should validate cleanly, same as any other opclass.
+SELECT amname, opcname
+FROM pg_opclass opc LEFT JOIN pg_am am ON am.oid = opcmethod
+WHERE opcname = 'gist_trgm_ops_noeq' AND NOT amvalidate(opc.oid);
+
+create table noeq_test (a text);
+insert into noeq_test select md5(g::text) from generate_series(1, 100) g;
+create index noeq_test_gist on noeq_test using gist (a gist_trgm_ops_noeq);
+
+-- The opclass doesn't offer '=' at all, so it must never be used for it,
+-- even with every alternative disabled.
+set enable_seqscan = off;
+set enable_bitmapscan = off;
+set enable_indexonlyscan = off;
+explain (costs off)
+  select * from noeq_test where a = '1234';
+
+-- Other operators still work, and give correct results.
+reset enable_seqscan;
+reset enable_bitmapscan;
+select count(*) from noeq_test where a like '%ab%';
+set enable_seqscan = off;
+explain (costs off)
+  select count(*) from noeq_test where a like '%ab%';
+select count(*) from noeq_test where a like '%ab%';
+reset enable_seqscan;
+
+drop table noeq_test;
diff --git a/doc/src/sgml/pgtrgm.sgml b/doc/src/sgml/pgtrgm.sgml
index 07bfcac9319..da4da138808 100644
--- a/doc/src/sgml/pgtrgm.sgml
+++ b/doc/src/sgml/pgtrgm.sgml
@@ -424,6 +424,14 @@
    for equality operator.
   </para>
 
+  <para>
+   The <literal>gist_trgm_ops_noeq</literal> operator class is identical to
+   <literal>gist_trgm_ops</literal>, except that it does not support
+   <literal>=</literal>.  It is meant for cases where a table already has a
+   B-tree index for equality lookups, and the trigram index is only needed
+   for the other operators above.
+  </para>
+
   <para>
    Example:
 
-- 
2.43.0

