From 2355f4ecbc8cab46a32bb5e997870f00c47aebea Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?= <michal.dtz@gmail.com>
Date: Mon, 6 Jul 2026 22:14:00 +0200
Subject: [PATCH v2 1/5] Allow schema-qualifying the operator in IS DISTINCT
 FROM/NULLIF

The IS [NOT] DISTINCT FROM and NULLIF constructs look up their equality
operator by the unqualified name "=" at parse time, and their syntax has
no place to write a schema-qualified operator name.  Consequently a view
or rule using them with an operator that is not reachable through the
prevailing search_path -- for example an extension type's "=" while
restoring a dump with the restricted search_path pg_dump uses -- either
fails to reload or, worse, silently resolves a different operator.

Provide the missing syntax: a IS [NOT] DISTINCT OPERATOR(schema.=) FROM b
and NULLIF(a, b USING OPERATOR(schema.=)).  The decoration is optional;
ruleutils.c emits it only when reparsing the undecorated construct would
not resolve the same operator -- i.e. when the operator is not reachable
by the bare name "=" under the prevailing search path, or is not named
"=" at all.  Parse analysis is unchanged: A_Expr.name already carries a
possibly-qualified name list.

Per discussion, this is the first of a series covering all constructs
cataloged in 10492.1531515255@sss.pgh.pa.us plus JOIN USING.

Document the optional decoration: the IS [NOT] DISTINCT FROM predicates
and the NULLIF section gain it in their synopses, each noting that the
"=" operator is resolved via the search path and that pg_dump emits the
decoration when the bare name would not resolve at restore time or the
operator is not named "=" at all; the OPERATOR() discussion in the
syntax chapter cross-references both.

Add a round-trip test to contrib/hstore, a real extension whose "=" lives
outside pg_catalog: views over NULLIF / IS [NOT] DISTINCT FROM on hstore
columns deparse with OPERATOR(public.=) once hstore's schema is off the
search_path, and reparse successfully under that restricted path.

Discussion: https://postgr.es/m/3856834.1783087886@sss.pgh.pa.us

---
 contrib/hstore/expected/hstore.out            |  88 ++++++
 contrib/hstore/sql/hstore.sql                 |  43 +++
 doc/src/sgml/func/func-comparison.sgml        |  23 +-
 doc/src/sgml/func/func-conditional.sgml       |  17 +-
 doc/src/sgml/syntax.sgml                      |  11 +
 src/backend/parser/gram.y                     |  20 ++
 src/backend/parser/parse_expr.c               |   7 +
 src/backend/utils/adt/ruleutils.c             |  50 +++-
 src/include/nodes/parsenodes.h                |   9 +-
 .../regress/expected/operator_qualify.out     | 250 ++++++++++++++++++
 src/test/regress/parallel_schedule            |   2 +-
 src/test/regress/sql/operator_qualify.sql     | 148 +++++++++++
 12 files changed, 658 insertions(+), 10 deletions(-)
 create mode 100644 src/test/regress/expected/operator_qualify.out
 create mode 100644 src/test/regress/sql/operator_qualify.sql

diff --git a/contrib/hstore/expected/hstore.out b/contrib/hstore/expected/hstore.out
index acea8806ba..159b409934 100644
--- a/contrib/hstore/expected/hstore.out
+++ b/contrib/hstore/expected/hstore.out
@@ -1632,3 +1632,91 @@ WHERE  hstore_hash(v)::bit(32) != hstore_hash_extended(v, 0)::bit(32)
 -------+----------+-----------+-----------
 (0 rows)
 
+-- Views using NULLIF or IS [NOT] DISTINCT FROM on hstore values must be
+-- deparsed with a schema-qualified equality operator when hstore's "="
+-- operator is not reachable through the search_path, so that the dumped
+-- definition reloads correctly (e.g. with the restricted search_path pg_dump
+-- uses).
+CREATE SCHEMA hstore_view_test;
+CREATE TABLE hstore_view_test.t (id int, c1 hstore, c2 hstore);
+CREATE VIEW hstore_view_test.v_nullif AS
+  SELECT id, NULLIF(c1, c2) AS r FROM hstore_view_test.t;
+CREATE VIEW hstore_view_test.v_distinct AS
+  SELECT id, c1 IS DISTINCT FROM c2 AS r FROM hstore_view_test.t;
+CREATE VIEW hstore_view_test.v_notdist AS
+  SELECT id, c1 IS NOT DISTINCT FROM c2 AS r FROM hstore_view_test.t;
+-- With hstore's schema (public) on the search_path the normal, unqualified
+-- syntax is used.
+SET search_path = hstore_view_test, public;
+SELECT pg_get_viewdef('hstore_view_test.v_nullif'::regclass, true);
+     pg_get_viewdef      
+-------------------------
+  SELECT id,            +
+     NULLIF(c1, c2) AS r+
+    FROM t;
+(1 row)
+
+SELECT pg_get_viewdef('hstore_view_test.v_distinct'::regclass, true);
+         pg_get_viewdef          
+---------------------------------
+  SELECT id,                    +
+     c1 IS DISTINCT FROM c2 AS r+
+    FROM t;
+(1 row)
+
+SELECT pg_get_viewdef('hstore_view_test.v_notdist'::regclass, true);
+           pg_get_viewdef            
+-------------------------------------
+  SELECT id,                        +
+     NOT c1 IS DISTINCT FROM c2 AS r+
+    FROM t;
+(1 row)
+
+-- With public off the search_path the operator must be schema-qualified,
+-- using the dedicated OPERATOR() positions of these constructs.
+SET search_path = hstore_view_test;
+SELECT pg_get_viewdef('hstore_view_test.v_nullif'::regclass, true);
+                  pg_get_viewdef                  
+--------------------------------------------------
+  SELECT id,                                     +
+     NULLIF(c1, c2 USING OPERATOR(public.=)) AS r+
+    FROM t;
+(1 row)
+
+SELECT pg_get_viewdef('hstore_view_test.v_distinct'::regclass, true);
+                   pg_get_viewdef                   
+----------------------------------------------------
+  SELECT id,                                       +
+     c1 IS DISTINCT OPERATOR(public.=) FROM c2 AS r+
+    FROM t;
+(1 row)
+
+SELECT pg_get_viewdef('hstore_view_test.v_notdist'::regclass, true);
+                     pg_get_viewdef                     
+--------------------------------------------------------
+  SELECT id,                                           +
+     NOT c1 IS DISTINCT OPERATOR(public.=) FROM c2 AS r+
+    FROM t;
+(1 row)
+
+-- The deparsed definitions must reparse successfully under that same
+-- restricted search_path (this is what failed before).
+DO $$
+BEGIN
+  EXECUTE 'CREATE VIEW hstore_view_test.v_nullif_reload AS '
+          || pg_get_viewdef('hstore_view_test.v_nullif'::regclass);
+  EXECUTE 'CREATE VIEW hstore_view_test.v_distinct_reload AS '
+          || pg_get_viewdef('hstore_view_test.v_distinct'::regclass);
+  EXECUTE 'CREATE VIEW hstore_view_test.v_notdist_reload AS '
+          || pg_get_viewdef('hstore_view_test.v_notdist'::regclass);
+END $$;
+RESET search_path;
+DROP SCHEMA hstore_view_test CASCADE;
+NOTICE:  drop cascades to 7 other objects
+DETAIL:  drop cascades to table hstore_view_test.t
+drop cascades to view hstore_view_test.v_nullif
+drop cascades to view hstore_view_test.v_distinct
+drop cascades to view hstore_view_test.v_notdist
+drop cascades to view hstore_view_test.v_nullif_reload
+drop cascades to view hstore_view_test.v_distinct_reload
+drop cascades to view hstore_view_test.v_notdist_reload
diff --git a/contrib/hstore/sql/hstore.sql b/contrib/hstore/sql/hstore.sql
index 8ae95e8a51..41654d0374 100644
--- a/contrib/hstore/sql/hstore.sql
+++ b/contrib/hstore/sql/hstore.sql
@@ -393,3 +393,46 @@ FROM   (VALUES (NULL::hstore), (''), ('"a key" =>1'), ('c => null'),
        ('e => 012345'), ('g => 2.345e+4')) x(v)
 WHERE  hstore_hash(v)::bit(32) != hstore_hash_extended(v, 0)::bit(32)
        OR hstore_hash(v)::bit(32) = hstore_hash_extended(v, 1)::bit(32);
+
+-- Views using NULLIF or IS [NOT] DISTINCT FROM on hstore values must be
+-- deparsed with a schema-qualified equality operator when hstore's "="
+-- operator is not reachable through the search_path, so that the dumped
+-- definition reloads correctly (e.g. with the restricted search_path pg_dump
+-- uses).
+CREATE SCHEMA hstore_view_test;
+CREATE TABLE hstore_view_test.t (id int, c1 hstore, c2 hstore);
+CREATE VIEW hstore_view_test.v_nullif AS
+  SELECT id, NULLIF(c1, c2) AS r FROM hstore_view_test.t;
+CREATE VIEW hstore_view_test.v_distinct AS
+  SELECT id, c1 IS DISTINCT FROM c2 AS r FROM hstore_view_test.t;
+CREATE VIEW hstore_view_test.v_notdist AS
+  SELECT id, c1 IS NOT DISTINCT FROM c2 AS r FROM hstore_view_test.t;
+
+-- With hstore's schema (public) on the search_path the normal, unqualified
+-- syntax is used.
+SET search_path = hstore_view_test, public;
+SELECT pg_get_viewdef('hstore_view_test.v_nullif'::regclass, true);
+SELECT pg_get_viewdef('hstore_view_test.v_distinct'::regclass, true);
+SELECT pg_get_viewdef('hstore_view_test.v_notdist'::regclass, true);
+
+-- With public off the search_path the operator must be schema-qualified,
+-- using the dedicated OPERATOR() positions of these constructs.
+SET search_path = hstore_view_test;
+SELECT pg_get_viewdef('hstore_view_test.v_nullif'::regclass, true);
+SELECT pg_get_viewdef('hstore_view_test.v_distinct'::regclass, true);
+SELECT pg_get_viewdef('hstore_view_test.v_notdist'::regclass, true);
+
+-- The deparsed definitions must reparse successfully under that same
+-- restricted search_path (this is what failed before).
+DO $$
+BEGIN
+  EXECUTE 'CREATE VIEW hstore_view_test.v_nullif_reload AS '
+          || pg_get_viewdef('hstore_view_test.v_nullif'::regclass);
+  EXECUTE 'CREATE VIEW hstore_view_test.v_distinct_reload AS '
+          || pg_get_viewdef('hstore_view_test.v_distinct'::regclass);
+  EXECUTE 'CREATE VIEW hstore_view_test.v_notdist_reload AS '
+          || pg_get_viewdef('hstore_view_test.v_notdist'::regclass);
+END $$;
+
+RESET search_path;
+DROP SCHEMA hstore_view_test CASCADE;
diff --git a/doc/src/sgml/func/func-comparison.sgml b/doc/src/sgml/func/func-comparison.sgml
index ecb1d89463..649d672c62 100644
--- a/doc/src/sgml/func/func-comparison.sgml
+++ b/doc/src/sgml/func/func-comparison.sgml
@@ -202,7 +202,7 @@
 
       <row>
        <entry role="func_table_entry"><para role="func_signature">
-        <replaceable>datatype</replaceable> <literal>IS DISTINCT FROM</literal> <replaceable>datatype</replaceable>
+        <replaceable>datatype</replaceable> <literal>IS DISTINCT</literal> <optional> <literal>OPERATOR(</literal><replaceable>operator</replaceable><literal>)</literal> </optional> <literal>FROM</literal> <replaceable>datatype</replaceable>
         <returnvalue>boolean</returnvalue>
        </para>
        <para>
@@ -220,7 +220,7 @@
 
       <row>
        <entry role="func_table_entry"><para role="func_signature">
-        <replaceable>datatype</replaceable> <literal>IS NOT DISTINCT FROM</literal> <replaceable>datatype</replaceable>
+        <replaceable>datatype</replaceable> <literal>IS NOT DISTINCT</literal> <optional> <literal>OPERATOR(</literal><replaceable>operator</replaceable><literal>)</literal> </optional> <literal>FROM</literal> <replaceable>datatype</replaceable>
         <returnvalue>boolean</returnvalue>
        </para>
        <para>
@@ -450,8 +450,8 @@
     this behavior is not suitable, use the
     <literal>IS <optional> NOT </optional> DISTINCT FROM</literal> predicates:
 <synopsis>
-<replaceable>a</replaceable> IS DISTINCT FROM <replaceable>b</replaceable>
-<replaceable>a</replaceable> IS NOT DISTINCT FROM <replaceable>b</replaceable>
+<replaceable>a</replaceable> IS DISTINCT <optional> OPERATOR(<replaceable>operator</replaceable>) </optional> FROM <replaceable>b</replaceable>
+<replaceable>a</replaceable> IS NOT DISTINCT <optional> OPERATOR(<replaceable>operator</replaceable>) </optional> FROM <replaceable>b</replaceable>
 </synopsis>
     For non-null inputs, <literal>IS DISTINCT FROM</literal> is
     the same as the <literal>&lt;&gt;</literal> operator.  However, if both
@@ -463,6 +463,21 @@
     were a normal data value, rather than <quote>unknown</quote>.
    </para>
 
+   <para>
+    The equality test underlying these predicates uses the operator
+    named <literal>=</literal>, resolved by name through the current search
+    path.  To pin a specific operator instead &mdash; for example one belonging
+    to an extension whose schema is not on the search path &mdash; write its
+    name with the optional <literal>OPERATOR()</literal> decoration
+    (see <xref linkend="sql-expressions-operator-calls"/>).  When a view or
+    rule that uses these predicates is dumped,
+    <application>pg_dump</application> emits this decoration automatically
+    whenever the bare name <literal>=</literal> would not resolve to the same
+    operator under the search path used at restore time, or the intended
+    operator is not named <literal>=</literal> at all, so that the definition
+    reloads unambiguously.
+   </para>
+
    <para>
     <indexterm>
      <primary>IS NULL</primary>
diff --git a/doc/src/sgml/func/func-conditional.sgml b/doc/src/sgml/func/func-conditional.sgml
index 7ca53dbf1a..137d5c7e43 100644
--- a/doc/src/sgml/func/func-conditional.sgml
+++ b/doc/src/sgml/func/func-conditional.sgml
@@ -210,7 +210,7 @@ SELECT COALESCE(description, short_description, '(none)') ...
   </indexterm>
 
 <synopsis>
-<function>NULLIF</function>(<replaceable>value1</replaceable>, <replaceable>value2</replaceable>)
+<function>NULLIF</function>(<replaceable>value1</replaceable>, <replaceable>value2</replaceable> <optional> USING OPERATOR(<replaceable>operator</replaceable>) </optional>)
 </synopsis>
 
   <para>
@@ -235,6 +235,21 @@ SELECT NULLIF(value, '(none)') ...
    suitable <literal>=</literal> operator available.
   </para>
 
+  <para>
+   That <literal>=</literal> operator is resolved by name through the current
+   search path.  To pin a specific operator instead &mdash; for example one
+   belonging to an extension whose schema is not on the search path &mdash;
+   give its name with the optional <literal>USING OPERATOR()</literal> clause
+   (see <xref linkend="sql-expressions-operator-calls"/> for
+   the <literal>OPERATOR()</literal> notation).  When a view or rule that
+   uses <function>NULLIF</function> is dumped,
+   <application>pg_dump</application> emits this clause automatically whenever
+   the bare name <literal>=</literal> would not resolve to the same operator
+   under the search path used at restore time, or the intended operator is not
+   named <literal>=</literal> at all, so that the definition reloads
+   unambiguously.
+  </para>
+
   <para>
    The result has the same type as the first argument &mdash; but there is
    a subtlety.  What is actually returned is the first argument of the
diff --git a/doc/src/sgml/syntax.sgml b/doc/src/sgml/syntax.sgml
index 6748299686..b5aad8b4c7 100644
--- a/doc/src/sgml/syntax.sgml
+++ b/doc/src/sgml/syntax.sgml
@@ -1128,6 +1128,17 @@ SELECT 3 OPERATOR(pg_catalog.+) 4;
     which specific operator appears inside <literal>OPERATOR()</literal>.
    </para>
 
+   <para>
+    A few constructs that resolve an equality operator only by its bare
+    name accept the <literal>OPERATOR()</literal> decoration in a dedicated
+    syntactic position of their own, so that a schema-qualified operator, or
+    one whose name is not <literal>=</literal>, can be pinned there as
+    well: <literal>IS <optional> NOT </optional> DISTINCT
+    FROM</literal> (see <xref linkend="functions-comparison-pred-table"/>)
+    and <function>NULLIF</function>
+    (see <xref linkend="functions-nullif"/>).
+   </para>
+
    <note>
     <para>
      <productname>PostgreSQL</productname> versions before 9.5 used slightly different
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index ff4e1388c5..54511313dc 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -16134,6 +16134,14 @@ a_expr:		c_expr									{ $$ = $1; }
 				{
 					$$ = (Node *) makeSimpleA_Expr(AEXPR_NOT_DISTINCT, "=", $1, $6, @2);
 				}
+			| a_expr IS DISTINCT OPERATOR '(' any_operator ')' FROM a_expr	%prec IS
+				{
+					$$ = (Node *) makeA_Expr(AEXPR_DISTINCT, $6, $1, $9, @2);
+				}
+			| a_expr IS NOT DISTINCT OPERATOR '(' any_operator ')' FROM a_expr	%prec IS
+				{
+					$$ = (Node *) makeA_Expr(AEXPR_NOT_DISTINCT, $7, $1, $10, @2);
+				}
 			| a_expr BETWEEN opt_asymmetric b_expr AND a_expr		%prec BETWEEN
 				{
 					$$ = (Node *) makeSimpleA_Expr(AEXPR_BETWEEN,
@@ -16397,6 +16405,14 @@ b_expr:		c_expr
 				{
 					$$ = (Node *) makeSimpleA_Expr(AEXPR_NOT_DISTINCT, "=", $1, $6, @2);
 				}
+			| b_expr IS DISTINCT OPERATOR '(' any_operator ')' FROM b_expr	%prec IS
+				{
+					$$ = (Node *) makeA_Expr(AEXPR_DISTINCT, $6, $1, $9, @2);
+				}
+			| b_expr IS NOT DISTINCT OPERATOR '(' any_operator ')' FROM b_expr	%prec IS
+				{
+					$$ = (Node *) makeA_Expr(AEXPR_NOT_DISTINCT, $7, $1, $10, @2);
+				}
 			| b_expr IS DOCUMENT_P					%prec IS
 				{
 					$$ = makeXmlExpr(IS_DOCUMENT, NULL, NIL,
@@ -16912,6 +16928,10 @@ func_expr_common_subexpr:
 				{
 					$$ = (Node *) makeSimpleA_Expr(AEXPR_NULLIF, "=", $3, $5, @1);
 				}
+			| NULLIF '(' a_expr ',' a_expr USING OPERATOR '(' any_operator ')' ')'
+				{
+					$$ = (Node *) makeA_Expr(AEXPR_NULLIF, $9, $3, $5, @1);
+				}
 			| COALESCE '(' expr_list ')'
 				{
 					CoalesceExpr *c = makeNode(CoalesceExpr);
diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c
index 9adc9d4c0f..0c5e1a3487 100644
--- a/src/backend/parser/parse_expr.c
+++ b/src/backend/parser/parse_expr.c
@@ -1054,6 +1054,13 @@ transformAExprDistinct(ParseState *pstate, A_Expr *a)
 	 * If either input is an undecorated NULL literal, transform to a NullTest
 	 * on the other input. That's simpler to process than a full DistinctExpr,
 	 * and it avoids needing to require that the datatype have an = operator.
+	 *
+	 * Note that any operator given explicitly (a->name, e.g. via the
+	 * OPERATOR() decoration) is deliberately neither consulted nor validated
+	 * in this path: "x IS DISTINCT FROM NULL" is equivalent to "x IS NOT
+	 * NULL" regardless of the operator, so the result does not depend on it
+	 * and we do not require that any such operator even exist (see
+	 * make_nulltest_from_distinct).
 	 */
 	if (exprIsNullConstant(rexpr))
 		return make_nulltest_from_distinct(pstate, a, lexpr);
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index 88de5c0481..8a9260fcd7 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -9958,11 +9958,37 @@ get_rule_expr(Node *node, deparse_context *context,
 				List	   *args = expr->args;
 				Node	   *arg1 = (Node *) linitial(args);
 				Node	   *arg2 = (Node *) lsecond(args);
+				char	   *opname;
+				bool		decorate;
 
+				/*
+				 * IS DISTINCT FROM resolves its operator by the unqualified
+				 * name "=" when it is parsed back.  We must therefore show
+				 * the operator explicitly -- a IS DISTINCT OPERATOR(s.=) FROM
+				 * b -- whenever reparsing the bare form would not recover
+				 * this same operator.  There are two such cases: the operator
+				 * is not reachable by an unqualified lookup of its own name
+				 * (then generate_operator_name() already returns the
+				 * OPERATOR(...) form), or its name is something other than
+				 * "=" (then a bare construct would wrongly resolve "="; wrap
+				 * the bare name in OPERATOR(), which any_operator syntax
+				 * accepts).
+				 */
+				opname = generate_operator_name(expr->opno,
+												exprType(arg1),
+												exprType(arg2));
+				decorate = (strncmp(opname, "OPERATOR(", 9) == 0) ||
+					(strcmp(opname, "=") != 0);
 				if (!PRETTY_PAREN(context))
 					appendStringInfoChar(buf, '(');
 				get_rule_expr_paren(arg1, context, true, node);
-				appendStringInfoString(buf, " IS DISTINCT FROM ");
+				if (!decorate)
+					appendStringInfoString(buf, " IS DISTINCT FROM ");
+				else if (strncmp(opname, "OPERATOR(", 9) == 0)
+					appendStringInfo(buf, " IS DISTINCT %s FROM ", opname);
+				else
+					appendStringInfo(buf, " IS DISTINCT OPERATOR(%s) FROM ",
+									 opname);
 				get_rule_expr_paren(arg2, context, true, node);
 				if (!PRETTY_PAREN(context))
 					appendStringInfoChar(buf, ')');
@@ -9972,9 +9998,31 @@ get_rule_expr(Node *node, deparse_context *context,
 		case T_NullIfExpr:
 			{
 				NullIfExpr *nullifexpr = (NullIfExpr *) node;
+				Node	   *arg1 = (Node *) linitial(nullifexpr->args);
+				Node	   *arg2 = (Node *) lsecond(nullifexpr->args);
+				char	   *opname;
+				bool		decorate;
 
+				/*
+				 * As above, but NULLIF(a, b USING OPERATOR(s.=)): decorate
+				 * whenever the bare form would reparse to a different
+				 * operator, i.e. the operator is not reachable by an
+				 * unqualified lookup of its own name, or its name is not "=".
+				 */
+				opname = generate_operator_name(nullifexpr->opno,
+												exprType(arg1),
+												exprType(arg2));
+				decorate = (strncmp(opname, "OPERATOR(", 9) == 0) ||
+					(strcmp(opname, "=") != 0);
 				appendStringInfoString(buf, "NULLIF(");
 				get_rule_expr((Node *) nullifexpr->args, context, true);
+				if (decorate)
+				{
+					if (strncmp(opname, "OPERATOR(", 9) == 0)
+						appendStringInfo(buf, " USING %s", opname);
+					else
+						appendStringInfo(buf, " USING OPERATOR(%s)", opname);
+				}
 				appendStringInfoChar(buf, ')');
 			}
 			break;
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index e03556399a..4fb4001475 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -335,9 +335,12 @@ typedef enum A_Expr_Kind
 	AEXPR_OP,					/* normal operator */
 	AEXPR_OP_ANY,				/* scalar op ANY (array) */
 	AEXPR_OP_ALL,				/* scalar op ALL (array) */
-	AEXPR_DISTINCT,				/* IS DISTINCT FROM - name must be "=" */
-	AEXPR_NOT_DISTINCT,			/* IS NOT DISTINCT FROM - name must be "=" */
-	AEXPR_NULLIF,				/* NULLIF - name must be "=" */
+	AEXPR_DISTINCT,				/* IS DISTINCT FROM - name is "=" or an
+								 * explicitly given equality operator */
+	AEXPR_NOT_DISTINCT,			/* IS NOT DISTINCT FROM - name is "=" or an
+								 * explicitly given equality operator */
+	AEXPR_NULLIF,				/* NULLIF - name is "=" or an explicitly given
+								 * equality operator */
 	AEXPR_IN,					/* [NOT] IN - name must be "=" or "<>" */
 	AEXPR_LIKE,					/* [NOT] LIKE - name must be "~~" or "!~~" */
 	AEXPR_ILIKE,				/* [NOT] ILIKE - name must be "~~*" or "!~~*" */
diff --git a/src/test/regress/expected/operator_qualify.out b/src/test/regress/expected/operator_qualify.out
new file mode 100644
index 0000000000..a812953b11
--- /dev/null
+++ b/src/test/regress/expected/operator_qualify.out
@@ -0,0 +1,250 @@
+--
+-- Verify that constructs which resolve an operator by unqualified name
+-- (IS [NOT] DISTINCT FROM, NULLIF, ...) deparse with a schema-qualified
+-- OPERATOR() decoration when the operator is not reachable via search_path,
+-- and that the decorated syntax parses back to the same semantics.
+--
+CREATE SCHEMA alt_ops;
+-- A shadow "=" over int4: same semantics as pg_catalog's (int4eq), but a
+-- distinct OID, so resolution differences are observable without contrib.
+CREATE OPERATOR alt_ops.= (
+    leftarg = int4, rightarg = int4, procedure = int4eq,
+    commutator = operator(alt_ops.=), restrict = eqsel, join = eqjoinsel,
+    hashes, merges
+);
+-- alt_ops.= declares "hashes", so back the declaration up: give the operator
+-- the hash opfamily membership the planner will look for whenever it builds
+-- a hash table with it (e.g. for a hashed subplan).
+CREATE OPERATOR CLASS alt_ops.int4_alt_hash_ops FOR TYPE int4 USING hash AS
+    OPERATOR 1 alt_ops.=,
+    FUNCTION 1 hashint4(int4);
+CREATE TABLE oq_t (id int, a int, b int);
+-- Views created while alt_ops.= shadows pg_catalog.=
+SET search_path = alt_ops, pg_catalog;
+CREATE VIEW public.oq_v_distinct AS
+  SELECT id, a IS DISTINCT FROM b AS r FROM public.oq_t;
+CREATE VIEW public.oq_v_notdistinct AS
+  SELECT id, a IS NOT DISTINCT FROM b AS r FROM public.oq_t;
+CREATE VIEW public.oq_v_nullif AS
+  SELECT id, NULLIF(a, b) AS r FROM public.oq_t;
+RESET search_path;
+-- With alt_ops off the search_path, the operator must be qualified.
+SELECT pg_get_viewdef('oq_v_distinct'::regclass, true);
+                  pg_get_viewdef                   
+---------------------------------------------------
+  SELECT id,                                      +
+     a IS DISTINCT OPERATOR(alt_ops.=) FROM b AS r+
+    FROM oq_t;
+(1 row)
+
+SELECT pg_get_viewdef('oq_v_notdistinct'::regclass, true);
+                    pg_get_viewdef                     
+-------------------------------------------------------
+  SELECT id,                                          +
+     NOT a IS DISTINCT OPERATOR(alt_ops.=) FROM b AS r+
+    FROM oq_t;
+(1 row)
+
+SELECT pg_get_viewdef('oq_v_nullif'::regclass, true);
+                 pg_get_viewdef                  
+-------------------------------------------------
+  SELECT id,                                    +
+     NULLIF(a, b USING OPERATOR(alt_ops.=)) AS r+
+    FROM oq_t;
+(1 row)
+
+-- With alt_ops reachable again, the plain syntax comes back.
+SET search_path = alt_ops, pg_catalog, public;
+SELECT pg_get_viewdef('oq_v_distinct'::regclass, true);
+        pg_get_viewdef         
+-------------------------------
+  SELECT id,                  +
+     a IS DISTINCT FROM b AS r+
+    FROM oq_t;
+(1 row)
+
+SELECT pg_get_viewdef('oq_v_nullif'::regclass, true);
+    pg_get_viewdef     
+-----------------------
+  SELECT id,          +
+     NULLIF(a, b) AS r+
+    FROM oq_t;
+(1 row)
+
+RESET search_path;
+-- A bare-resolvable operator whose name is not "=" must still be decorated:
+-- reparsing the undecorated construct would resolve "=", not this operator.
+-- pg_catalog.< is on the default search_path, so its name prints unqualified,
+-- but it is wrapped in OPERATOR() so the reparsed semantics stay identical.
+CREATE VIEW public.oq_v_distinct_lt AS SELECT 1 IS DISTINCT OPERATOR(<) FROM 2 AS r;
+CREATE VIEW public.oq_v_nullif_lt AS SELECT NULLIF(1, 2 USING OPERATOR(<)) AS r;
+SELECT pg_get_viewdef('oq_v_distinct_lt'::regclass, true);
+                 pg_get_viewdef                 
+------------------------------------------------
+  SELECT 1 IS DISTINCT OPERATOR(<) FROM 2 AS r;
+(1 row)
+
+SELECT pg_get_viewdef('oq_v_nullif_lt'::regclass, true);
+                pg_get_viewdef                
+----------------------------------------------
+  SELECT NULLIF(1, 2 USING OPERATOR(<)) AS r;
+(1 row)
+
+-- The decorated syntax must be directly acceptable (a_expr and b_expr).
+SELECT 1 IS DISTINCT OPERATOR(alt_ops.=) FROM 2 AS t;
+ t 
+---
+ t
+(1 row)
+
+SELECT 1 IS NOT DISTINCT OPERATOR(alt_ops.=) FROM 2 AS f;
+ f 
+---
+ f
+(1 row)
+
+SELECT NULLIF(1, 2 USING OPERATOR(alt_ops.=)) AS one;
+ one 
+-----
+   1
+(1 row)
+
+SELECT NULLIF(2, 2 USING OPERATOR(alt_ops.=)) AS n;
+ n 
+---
+  
+(1 row)
+
+-- b_expr context (typmod-less contexts use b_expr)
+CREATE TABLE oq_bexpr_check (x bool DEFAULT 1 IS DISTINCT OPERATOR(pg_catalog.=) FROM 2);
+DROP TABLE oq_bexpr_check;
+-- unqualified name inside the decoration is fine too
+SELECT 1 IS DISTINCT OPERATOR(=) FROM 2 AS t;
+ t 
+---
+ t
+(1 row)
+
+-- Semantics identical to the plain construct, including NULL handling.
+SELECT x IS DISTINCT OPERATOR(alt_ops.=) FROM y AS dist,
+       NULLIF(x, y USING OPERATOR(alt_ops.=)) AS nif
+FROM (VALUES (1, 1), (1, 2), (NULL::int, 1), (NULL::int, NULL::int)) v(x, y);
+ dist | nif 
+------+-----
+ f    |    
+ t    |   1
+ t    |    
+ f    |    
+(4 rows)
+
+-- A non-boolean operator is rejected, same as parse analysis rejects today.
+CREATE OPERATOR alt_ops.+ (leftarg = int4, rightarg = int4, procedure = int4pl);
+SELECT NULLIF(1, 2 USING OPERATOR(alt_ops.+));
+ERROR:  NULLIF requires = operator to yield boolean
+LINE 1: SELECT NULLIF(1, 2 USING OPERATOR(alt_ops.+));
+               ^
+SELECT 1 IS DISTINCT OPERATOR(alt_ops.+) FROM 2;
+ERROR:  IS DISTINCT FROM requires = operator to yield boolean
+LINE 1: SELECT 1 IS DISTINCT OPERATOR(alt_ops.+) FROM 2;
+                 ^
+-- When one side is the NULL literal, the construct degenerates to a
+-- NullTest and the given operator is (deliberately) not consulted.
+SELECT 1 IS DISTINCT OPERATOR(alt_ops.=) FROM NULL AS t;
+ t 
+---
+ t
+(1 row)
+
+-- The deparsed definitions must reparse under a restricted search_path
+-- (this is what pg_dump does; it failed before this patch).
+BEGIN;
+SET LOCAL search_path = pg_catalog;
+DO $$
+BEGIN
+  EXECUTE 'CREATE VIEW public.oq_v_distinct_reload AS '
+          || pg_get_viewdef('public.oq_v_distinct'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_notdistinct_reload AS '
+          || pg_get_viewdef('public.oq_v_notdistinct'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_nullif_reload AS '
+          || pg_get_viewdef('public.oq_v_nullif'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_distinct_lt_reload AS '
+          || pg_get_viewdef('public.oq_v_distinct_lt'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_nullif_lt_reload AS '
+          || pg_get_viewdef('public.oq_v_nullif_lt'::regclass);
+END $$;
+COMMIT;
+-- Reloaded views resolve the same operator OIDs as the originals.
+SELECT ev_class::regclass FROM pg_rewrite
+WHERE ev_action LIKE '%:opno ' || 'alt_ops.=(int4,int4)'::regoperator::oid || ' %'
+  AND ev_class::regclass::text LIKE 'oq_v%reload'
+ORDER BY 1;
+        ev_class         
+-------------------------
+ oq_v_distinct_reload
+ oq_v_notdistinct_reload
+ oq_v_nullif_reload
+(3 rows)
+
+-- A bare-but-non-"=" operator (here pg_catalog.<) survives the round-trip too:
+-- the OPERATOR(<) decoration pins int4lt instead of letting "=" resolve.
+SELECT ev_class::regclass FROM pg_rewrite
+WHERE ev_action LIKE '%:opno ' || 'pg_catalog.<(int4,int4)'::regoperator::oid || ' %'
+  AND ev_class::regclass::text LIKE 'oq_v%lt_reload%'
+ORDER BY 1;
+        ev_class         
+-------------------------
+ oq_v_distinct_lt_reload
+ oq_v_nullif_lt_reload
+(2 rows)
+
+DROP VIEW oq_v_distinct_reload, oq_v_notdistinct_reload, oq_v_nullif_reload,
+          oq_v_distinct_lt_reload, oq_v_nullif_lt_reload;
+--
+-- Trigger WHEN clauses deparse through the same code path
+-- (pg_get_triggerdef).  This is the dominant real-world shape of the
+-- problem: a BEFORE UPDATE trigger whose WHEN test uses IS DISTINCT FROM on
+-- a column whose "=" is off the search_path used at restore time.
+--
+CREATE FUNCTION oq_trgfn() RETURNS trigger LANGUAGE plpgsql
+  AS $$ BEGIN RETURN NEW; END $$;
+SET search_path = alt_ops, pg_catalog;
+CREATE TRIGGER oq_trg BEFORE UPDATE ON public.oq_t FOR EACH ROW
+  WHEN (OLD.a IS DISTINCT FROM NEW.a) EXECUTE FUNCTION public.oq_trgfn();
+RESET search_path;
+-- With alt_ops off the search_path the WHEN operator must be qualified.
+SELECT pg_get_triggerdef(oid, true) FROM pg_trigger WHERE tgname = 'oq_trg';
+                                                              pg_get_triggerdef                                                               
+----------------------------------------------------------------------------------------------------------------------------------------------
+ CREATE TRIGGER oq_trg BEFORE UPDATE ON oq_t FOR EACH ROW WHEN (old.a IS DISTINCT OPERATOR(alt_ops.=) FROM new.a) EXECUTE FUNCTION oq_trgfn()
+(1 row)
+
+-- The deparsed definition must recreate the trigger under a restricted
+-- search_path (as pg_dump does at restore time).  alt_ops is deliberately
+-- absent below, so the OPERATOR(alt_ops.=) decoration in the WHEN clause is
+-- what pins the correct operator; public is present only so the trigger's
+-- table and function resolve.  Capture the definition before dropping.
+BEGIN;
+SET LOCAL search_path = public, pg_catalog;
+DO $$
+DECLARE def text;
+BEGIN
+  SELECT pg_get_triggerdef(oid) INTO def FROM pg_trigger
+    WHERE tgname = 'oq_trg' AND tgrelid = 'public.oq_t'::regclass;
+  EXECUTE 'DROP TRIGGER oq_trg ON public.oq_t';
+  EXECUTE def;
+END $$;
+COMMIT;
+-- The reloaded trigger's WHEN condition resolves the shadow alt_ops.= (its
+-- OID appears in tgqual), not pg_catalog.=.
+SELECT tgname FROM pg_trigger
+WHERE tgqual LIKE '%:opno ' || 'alt_ops.=(int4,int4)'::regoperator::oid || ' %'
+  AND tgname = 'oq_trg';
+ tgname 
+--------
+ oq_trg
+(1 row)
+
+-- NOTE: oq_t, the oq_v_* views, and the oq_trg trigger (backed by oq_trgfn)
+-- are intentionally kept (not dropped): the pg_upgrade test suite
+-- dump/restores the regression database and thereby exercises the qualified
+-- deparse end-to-end.
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 8fa0a6c47f..fe25b6b2cc 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -81,7 +81,7 @@ test: create_table_like alter_generic alter_operator misc async dbsize merge mis
 # collate.linux.utf8 and collate.icu.utf8 tests cannot be run in parallel with each other
 # psql depends on create_am
 # amutils depends on geometry, create_index_spgist, hash_index, brin
-test: rules psql psql_crosstab psql_pipeline amutils stats_ext collate.linux.utf8 collate.windows.win1252
+test: rules psql psql_crosstab psql_pipeline amutils stats_ext collate.linux.utf8 collate.windows.win1252 operator_qualify
 
 # ----------
 # Run these alone so they don't run out of parallel workers
diff --git a/src/test/regress/sql/operator_qualify.sql b/src/test/regress/sql/operator_qualify.sql
new file mode 100644
index 0000000000..41144ba867
--- /dev/null
+++ b/src/test/regress/sql/operator_qualify.sql
@@ -0,0 +1,148 @@
+--
+-- Verify that constructs which resolve an operator by unqualified name
+-- (IS [NOT] DISTINCT FROM, NULLIF, ...) deparse with a schema-qualified
+-- OPERATOR() decoration when the operator is not reachable via search_path,
+-- and that the decorated syntax parses back to the same semantics.
+--
+CREATE SCHEMA alt_ops;
+-- A shadow "=" over int4: same semantics as pg_catalog's (int4eq), but a
+-- distinct OID, so resolution differences are observable without contrib.
+CREATE OPERATOR alt_ops.= (
+    leftarg = int4, rightarg = int4, procedure = int4eq,
+    commutator = operator(alt_ops.=), restrict = eqsel, join = eqjoinsel,
+    hashes, merges
+);
+-- alt_ops.= declares "hashes", so back the declaration up: give the operator
+-- the hash opfamily membership the planner will look for whenever it builds
+-- a hash table with it (e.g. for a hashed subplan).
+CREATE OPERATOR CLASS alt_ops.int4_alt_hash_ops FOR TYPE int4 USING hash AS
+    OPERATOR 1 alt_ops.=,
+    FUNCTION 1 hashint4(int4);
+CREATE TABLE oq_t (id int, a int, b int);
+
+-- Views created while alt_ops.= shadows pg_catalog.=
+SET search_path = alt_ops, pg_catalog;
+CREATE VIEW public.oq_v_distinct AS
+  SELECT id, a IS DISTINCT FROM b AS r FROM public.oq_t;
+CREATE VIEW public.oq_v_notdistinct AS
+  SELECT id, a IS NOT DISTINCT FROM b AS r FROM public.oq_t;
+CREATE VIEW public.oq_v_nullif AS
+  SELECT id, NULLIF(a, b) AS r FROM public.oq_t;
+RESET search_path;
+
+-- With alt_ops off the search_path, the operator must be qualified.
+SELECT pg_get_viewdef('oq_v_distinct'::regclass, true);
+SELECT pg_get_viewdef('oq_v_notdistinct'::regclass, true);
+SELECT pg_get_viewdef('oq_v_nullif'::regclass, true);
+
+-- With alt_ops reachable again, the plain syntax comes back.
+SET search_path = alt_ops, pg_catalog, public;
+SELECT pg_get_viewdef('oq_v_distinct'::regclass, true);
+SELECT pg_get_viewdef('oq_v_nullif'::regclass, true);
+RESET search_path;
+
+-- A bare-resolvable operator whose name is not "=" must still be decorated:
+-- reparsing the undecorated construct would resolve "=", not this operator.
+-- pg_catalog.< is on the default search_path, so its name prints unqualified,
+-- but it is wrapped in OPERATOR() so the reparsed semantics stay identical.
+CREATE VIEW public.oq_v_distinct_lt AS SELECT 1 IS DISTINCT OPERATOR(<) FROM 2 AS r;
+CREATE VIEW public.oq_v_nullif_lt AS SELECT NULLIF(1, 2 USING OPERATOR(<)) AS r;
+SELECT pg_get_viewdef('oq_v_distinct_lt'::regclass, true);
+SELECT pg_get_viewdef('oq_v_nullif_lt'::regclass, true);
+
+-- The decorated syntax must be directly acceptable (a_expr and b_expr).
+SELECT 1 IS DISTINCT OPERATOR(alt_ops.=) FROM 2 AS t;
+SELECT 1 IS NOT DISTINCT OPERATOR(alt_ops.=) FROM 2 AS f;
+SELECT NULLIF(1, 2 USING OPERATOR(alt_ops.=)) AS one;
+SELECT NULLIF(2, 2 USING OPERATOR(alt_ops.=)) AS n;
+-- b_expr context (typmod-less contexts use b_expr)
+CREATE TABLE oq_bexpr_check (x bool DEFAULT 1 IS DISTINCT OPERATOR(pg_catalog.=) FROM 2);
+DROP TABLE oq_bexpr_check;
+-- unqualified name inside the decoration is fine too
+SELECT 1 IS DISTINCT OPERATOR(=) FROM 2 AS t;
+
+-- Semantics identical to the plain construct, including NULL handling.
+SELECT x IS DISTINCT OPERATOR(alt_ops.=) FROM y AS dist,
+       NULLIF(x, y USING OPERATOR(alt_ops.=)) AS nif
+FROM (VALUES (1, 1), (1, 2), (NULL::int, 1), (NULL::int, NULL::int)) v(x, y);
+
+-- A non-boolean operator is rejected, same as parse analysis rejects today.
+CREATE OPERATOR alt_ops.+ (leftarg = int4, rightarg = int4, procedure = int4pl);
+SELECT NULLIF(1, 2 USING OPERATOR(alt_ops.+));
+SELECT 1 IS DISTINCT OPERATOR(alt_ops.+) FROM 2;
+
+-- When one side is the NULL literal, the construct degenerates to a
+-- NullTest and the given operator is (deliberately) not consulted.
+SELECT 1 IS DISTINCT OPERATOR(alt_ops.=) FROM NULL AS t;
+
+-- The deparsed definitions must reparse under a restricted search_path
+-- (this is what pg_dump does; it failed before this patch).
+BEGIN;
+SET LOCAL search_path = pg_catalog;
+DO $$
+BEGIN
+  EXECUTE 'CREATE VIEW public.oq_v_distinct_reload AS '
+          || pg_get_viewdef('public.oq_v_distinct'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_notdistinct_reload AS '
+          || pg_get_viewdef('public.oq_v_notdistinct'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_nullif_reload AS '
+          || pg_get_viewdef('public.oq_v_nullif'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_distinct_lt_reload AS '
+          || pg_get_viewdef('public.oq_v_distinct_lt'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_nullif_lt_reload AS '
+          || pg_get_viewdef('public.oq_v_nullif_lt'::regclass);
+END $$;
+COMMIT;
+-- Reloaded views resolve the same operator OIDs as the originals.
+SELECT ev_class::regclass FROM pg_rewrite
+WHERE ev_action LIKE '%:opno ' || 'alt_ops.=(int4,int4)'::regoperator::oid || ' %'
+  AND ev_class::regclass::text LIKE 'oq_v%reload'
+ORDER BY 1;
+-- A bare-but-non-"=" operator (here pg_catalog.<) survives the round-trip too:
+-- the OPERATOR(<) decoration pins int4lt instead of letting "=" resolve.
+SELECT ev_class::regclass FROM pg_rewrite
+WHERE ev_action LIKE '%:opno ' || 'pg_catalog.<(int4,int4)'::regoperator::oid || ' %'
+  AND ev_class::regclass::text LIKE 'oq_v%lt_reload%'
+ORDER BY 1;
+DROP VIEW oq_v_distinct_reload, oq_v_notdistinct_reload, oq_v_nullif_reload,
+          oq_v_distinct_lt_reload, oq_v_nullif_lt_reload;
+
+--
+-- Trigger WHEN clauses deparse through the same code path
+-- (pg_get_triggerdef).  This is the dominant real-world shape of the
+-- problem: a BEFORE UPDATE trigger whose WHEN test uses IS DISTINCT FROM on
+-- a column whose "=" is off the search_path used at restore time.
+--
+CREATE FUNCTION oq_trgfn() RETURNS trigger LANGUAGE plpgsql
+  AS $$ BEGIN RETURN NEW; END $$;
+SET search_path = alt_ops, pg_catalog;
+CREATE TRIGGER oq_trg BEFORE UPDATE ON public.oq_t FOR EACH ROW
+  WHEN (OLD.a IS DISTINCT FROM NEW.a) EXECUTE FUNCTION public.oq_trgfn();
+RESET search_path;
+-- With alt_ops off the search_path the WHEN operator must be qualified.
+SELECT pg_get_triggerdef(oid, true) FROM pg_trigger WHERE tgname = 'oq_trg';
+-- The deparsed definition must recreate the trigger under a restricted
+-- search_path (as pg_dump does at restore time).  alt_ops is deliberately
+-- absent below, so the OPERATOR(alt_ops.=) decoration in the WHEN clause is
+-- what pins the correct operator; public is present only so the trigger's
+-- table and function resolve.  Capture the definition before dropping.
+BEGIN;
+SET LOCAL search_path = public, pg_catalog;
+DO $$
+DECLARE def text;
+BEGIN
+  SELECT pg_get_triggerdef(oid) INTO def FROM pg_trigger
+    WHERE tgname = 'oq_trg' AND tgrelid = 'public.oq_t'::regclass;
+  EXECUTE 'DROP TRIGGER oq_trg ON public.oq_t';
+  EXECUTE def;
+END $$;
+COMMIT;
+-- The reloaded trigger's WHEN condition resolves the shadow alt_ops.= (its
+-- OID appears in tgqual), not pg_catalog.=.
+SELECT tgname FROM pg_trigger
+WHERE tgqual LIKE '%:opno ' || 'alt_ops.=(int4,int4)'::regoperator::oid || ' %'
+  AND tgname = 'oq_trg';
+-- NOTE: oq_t, the oq_v_* views, and the oq_trg trigger (backed by oq_trgfn)
+-- are intentionally kept (not dropped): the pg_upgrade test suite
+-- dump/restores the regression database and thereby exercises the qualified
+-- deparse end-to-end.
-- 
2.54.0

