From e50e151b1be1c89085c1474a1d90361474140a35 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?= <michal.dtz@gmail.com>
Date: Tue, 7 Jul 2026 00:07:10 +0200
Subject: [PATCH v2 2/5] Allow schema-qualifying the operators of JOIN USING

JOIN ... USING resolves each column's equality operator by the
unqualified name "=" at parse time, and its syntax has no place to
write a schema-qualified operator name.  Consequently a view or rule
whose join operator 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.  In one real-world incident, a
materialized view joining on a domain over citext silently resolved
texteq after dump/restore and returned wrong results:
https://postgr.es/m/CAC35HNnNGavaZ%3DP%3DrUcwTwYEhfoyXDg32REXCRDgxBmC3No3nA%40mail.gmail.com

Provide the missing syntax: an optional per-column operator list

    JOIN tab USING (a, b) OPERATOR (schema.=, =) [AS alias]

whose members may be schema-qualified; the n'th operator is used to
compare the n'th USING column.  NATURAL JOIN has no syntactic slot of
its own, but since parse analysis converts it to the equivalent USING
form, such a join deparses as USING (...) OPERATOR (...) whenever
qualification is needed.  The decoration is optional; ruleutils.c emits
the list only when reparsing a bare USING clause would not recover the
same operators -- i.e. when some column's operator is not reachable by
an unqualified lookup of its name, or is not named "=" at all (a bare
clause always reparses as "=").  This mirrors the rule that governs
plain OpExpr deparsing.

JoinExpr gains a usingOperators field carrying the per-column operator
names, so catversion is bumped.

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

---
 doc/src/sgml/queries.sgml                     |  12 ++
 doc/src/sgml/ref/select.sgml                  |  18 ++-
 src/backend/parser/gram.y                     |  44 +++++--
 src/backend/parser/parse_clause.c             |  46 ++++++-
 src/backend/utils/adt/ruleutils.c             | 124 +++++++++++++++++-
 src/include/catalog/catversion.h              |   2 +-
 src/include/nodes/primnodes.h                 |   9 ++
 .../regress/expected/operator_qualify.out     |  97 ++++++++++++++
 src/test/regress/sql/operator_qualify.sql     |  49 +++++++
 9 files changed, 372 insertions(+), 29 deletions(-)

diff --git a/doc/src/sgml/queries.sgml b/doc/src/sgml/queries.sgml
index d8d4c3c53e..4529317183 100644
--- a/doc/src/sgml/queries.sgml
+++ b/doc/src/sgml/queries.sgml
@@ -369,6 +369,18 @@ FROM <replaceable>table_reference</replaceable> <optional>, <replaceable>table_r
         = <replaceable>T2</replaceable>.b</literal>.
        </para>
 
+       <para>
+        By default each comparison uses the <literal>=</literal> operator
+        found in the search path.  An optional operator list, written as
+        <literal>USING (a, b) OPERATOR (<replaceable>operator</replaceable>,
+        <replaceable>operator</replaceable>)</literal>, pins the equality
+        operator used for each join column: one operator per column, and
+        each name can be schema-qualified.  <application>pg_dump</application>
+        emits this clause when the join would not otherwise use the same
+        operators, either because an operator is not reachable by an
+        unqualified name or because it is not named <literal>=</literal>.
+       </para>
+
        <para>
         Furthermore, the output of <literal>JOIN USING</literal> suppresses
         redundant columns: there is no need to print both of the matched
diff --git a/doc/src/sgml/ref/select.sgml b/doc/src/sgml/ref/select.sgml
index 09b6ce809b..e2a01535b4 100644
--- a/doc/src/sgml/ref/select.sgml
+++ b/doc/src/sgml/ref/select.sgml
@@ -60,7 +60,7 @@ SELECT [ ALL | DISTINCT [ ON ( <replaceable class="parameter">expression</replac
     [ LATERAL ] ROWS FROM( <replaceable class="parameter">function_name</replaceable> ( [ <replaceable class="parameter">argument</replaceable> [, ...] ] ) [ AS ( <replaceable class="parameter">column_definition</replaceable> [, ...] ) ] [, ...] )
                 [ WITH ORDINALITY ] [ [ AS ] <replaceable class="parameter">alias</replaceable> [ ( <replaceable class="parameter">column_alias</replaceable> [, ...] ) ] ]
     GRAPH_TABLE ( <replaceable class="parameter">graph_name</replaceable> MATCH <replaceable class="parameter">graph_pattern</replaceable> COLUMNS ( { <replaceable class="parameter">expression</replaceable> [ AS <replaceable class="parameter">name</replaceable> ] } [, ...] ) ) [ [ AS ] <replaceable class="parameter">alias</replaceable> [ ( <replaceable class="parameter">column_alias</replaceable> [, ...] ) ] ]
-    <replaceable class="parameter">from_item</replaceable> <replaceable class="parameter">join_type</replaceable> <replaceable class="parameter">from_item</replaceable> { ON <replaceable class="parameter">join_condition</replaceable> | USING ( <replaceable class="parameter">join_column</replaceable> [, ...] ) [ AS <replaceable class="parameter">join_using_alias</replaceable> ] }
+    <replaceable class="parameter">from_item</replaceable> <replaceable class="parameter">join_type</replaceable> <replaceable class="parameter">from_item</replaceable> { ON <replaceable class="parameter">join_condition</replaceable> | USING ( <replaceable class="parameter">join_column</replaceable> [, ...] ) [ OPERATOR ( <replaceable class="parameter">operator</replaceable> [, ...] ) ] [ AS <replaceable class="parameter">join_using_alias</replaceable> ] }
     <replaceable class="parameter">from_item</replaceable> NATURAL <replaceable class="parameter">join_type</replaceable> <replaceable class="parameter">from_item</replaceable>
     <replaceable class="parameter">from_item</replaceable> CROSS JOIN <replaceable class="parameter">from_item</replaceable>
 
@@ -712,7 +712,7 @@ TABLE [ ONLY ] <replaceable class="parameter">table_name</replaceable> [ * ]
      </varlistentry>
 
      <varlistentry>
-      <term><literal>USING ( <replaceable class="parameter">join_column</replaceable> [, ...] ) [ AS <replaceable class="parameter">join_using_alias</replaceable> ]</literal></term>
+      <term><literal>USING ( <replaceable class="parameter">join_column</replaceable> [, ...] ) [ OPERATOR ( <replaceable class="parameter">operator</replaceable> [, ...] ) ] [ AS <replaceable class="parameter">join_using_alias</replaceable> ]</literal></term>
       <listitem>
        <para>
         A clause of the form <literal>USING ( a, b, ... )</literal> is
@@ -723,6 +723,20 @@ TABLE [ ONLY ] <replaceable class="parameter">table_name</replaceable> [ * ]
         both.
        </para>
 
+       <para>
+        Normally each column's comparison uses the <literal>=</literal>
+        operator found in the search path.  The optional
+        <literal>OPERATOR</literal> clause pins the equality operator used
+        for each join column instead: it must list exactly one
+        <replaceable class="parameter">operator</replaceable> per
+        <replaceable class="parameter">join_column</replaceable>, and each
+        operator name can be schema-qualified.
+        <application>pg_dump</application> emits this clause when the join
+        would not otherwise use the same operators, either because an operator
+        is not reachable by an unqualified name or because it is not
+        named <literal>=</literal>.
+       </para>
+
        <para>
         If a <replaceable class="parameter">join_using_alias</replaceable>
         name is specified, it provides a table alias for the join columns.
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 54511313dc..2df4192e2a 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -492,6 +492,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 
 %type <node>	join_qual
 %type <jtype>	join_type
+%type <list>	opt_join_using_operators any_operator_list
 
 %type <list>	extract_list overlay_list position_list
 %type <list>	substr_list trim_list
@@ -14581,7 +14582,8 @@ joined_table:
 					{
 						 /* USING clause */
 						n->usingClause = linitial_node(List, castNode(List, $5));
-						n->join_using_alias = lsecond_node(Alias, castNode(List, $5));
+						n->usingOperators = lsecond_node(List, castNode(List, $5));
+						n->join_using_alias = lthird_node(Alias, castNode(List, $5));
 					}
 					else
 					{
@@ -14603,7 +14605,8 @@ joined_table:
 					{
 						/* USING clause */
 						n->usingClause = linitial_node(List, castNode(List, $4));
-						n->join_using_alias = lsecond_node(Alias, castNode(List, $4));
+						n->usingOperators = lsecond_node(List, castNode(List, $4));
+						n->join_using_alias = lthird_node(Alias, castNode(List, $4));
 					}
 					else
 					{
@@ -14671,10 +14674,13 @@ opt_alias_clause: alias_clause						{ $$ = $1; }
 		;
 
 /*
- * The alias clause after JOIN ... USING only accepts the AS ColId spelling,
- * per SQL standard.  (The grammar could parse the other variants, but they
- * don't seem to be useful, and it might lead to parser problems in the
- * future.)
+ * A JOIN ... USING clause is spelled
+ *		USING ( column list ) [ OPERATOR ( operator list ) ] [ AS alias ]
+ * The optional OPERATOR clause (opt_join_using_operators) sits between the
+ * column list and the alias.  The alias clause itself only accepts the AS
+ * ColId spelling, per SQL standard.  (The grammar could parse the other alias
+ * variants, but they don't seem to be useful, and it might lead to parser
+ * problems in the future.)
  */
 opt_alias_clause_for_join_using:
 			AS ColId
@@ -14732,19 +14738,23 @@ opt_outer: OUTER_P
 
 /* JOIN qualification clauses
  * Possibilities are:
- *	USING ( column list ) [ AS alias ]
+ *	USING ( column list ) [ OPERATOR ( operator list ) ] [ AS alias ]
  *						  allows only unqualified column names,
- *						  which must match between tables.
+ *						  which must match between tables.  The optional
+ *						  OPERATOR clause supplies a per-column, possibly
+ *						  schema-qualified equality operator name in place
+ *						  of the implicit "=".
  *	ON expr allows more general qualifications.
  *
- * We return USING as a two-element List (the first item being a sub-List
- * of the common column names, and the second either an Alias item or NULL).
+ * We return USING as a three-element List (the first item being a sub-List
+ * of the common column names, the second the sub-List of per-column operator
+ * names, or NIL, and the third either an Alias item or NULL).
  * An ON-expr will not be a List, so it can be told apart that way.
  */
 
-join_qual: USING '(' name_list ')' opt_alias_clause_for_join_using
+join_qual: USING '(' name_list ')' opt_join_using_operators opt_alias_clause_for_join_using
 				{
-					$$ = (Node *) list_make2($3, $5);
+					$$ = (Node *) list_make3($3, $5, $6);
 				}
 			| ON a_expr
 				{
@@ -14752,6 +14762,16 @@ join_qual: USING '(' name_list ')' opt_alias_clause_for_join_using
 				}
 		;
 
+opt_join_using_operators:
+			OPERATOR '(' any_operator_list ')'		{ $$ = $3; }
+			| /*EMPTY*/								{ $$ = NIL; }
+		;
+
+any_operator_list:
+			any_operator							{ $$ = list_make1($1); }
+			| any_operator_list ',' any_operator	{ $$ = lappend($1, $3); }
+		;
+
 
 relation_expr:
 			qualified_name
diff --git a/src/backend/parser/parse_clause.c b/src/backend/parser/parse_clause.c
index 881fba2e7b..1642492098 100644
--- a/src/backend/parser/parse_clause.c
+++ b/src/backend/parser/parse_clause.c
@@ -57,7 +57,8 @@ static int	extractRemainingColumns(ParseState *pstate,
 									List **res_colnames, List **res_colvars,
 									ParseNamespaceColumn *res_nscolumns);
 static Node *transformJoinUsingClause(ParseState *pstate,
-									  List *leftVars, List *rightVars);
+									  List *leftVars, List *rightVars,
+									  List *usingOperators);
 static Node *transformJoinOnClause(ParseState *pstate, JoinExpr *j,
 								   List *namespace);
 static ParseNamespaceItem *transformTableEntry(ParseState *pstate, RangeVar *r);
@@ -309,12 +310,14 @@ extractRemainingColumns(ParseState *pstate,
  */
 static Node *
 transformJoinUsingClause(ParseState *pstate,
-						 List *leftVars, List *rightVars)
+						 List *leftVars, List *rightVars,
+						 List *usingOperators)
 {
 	Node	   *result;
 	List	   *andargs = NIL;
 	ListCell   *lvars,
 			   *rvars;
+	int			i;
 
 	/*
 	 * We cheat a little bit here by building an untransformed operator tree
@@ -324,23 +327,37 @@ transformJoinUsingClause(ParseState *pstate,
 	 * have to mark the columns as requiring SELECT privilege for ourselves;
 	 * transformExpr() won't do it.
 	 */
+	i = 0;
 	forboth(lvars, leftVars, rvars, rightVars)
 	{
 		Var		   *lvar = (Var *) lfirst(lvars);
 		Var		   *rvar = (Var *) lfirst(rvars);
 		A_Expr	   *e;
+		List	   *opname;
 
 		/* Require read access to the join variables */
 		markVarForSelectPriv(pstate, lvar);
 		markVarForSelectPriv(pstate, rvar);
 
-		/* Now create the lvar = rvar join condition */
-		e = makeSimpleA_Expr(AEXPR_OP, "=",
-							 (Node *) copyObject(lvar), (Node *) copyObject(rvar),
-							 -1);
+		/*
+		 * Choose the operator name: an explicit USING ... OPERATOR (...)
+		 * entry for this column if one was given, else the implicit "=".  The
+		 * caller has already checked that usingOperators, when present, has
+		 * the same length as the column list.
+		 */
+		if (usingOperators != NIL)
+			opname = (List *) list_nth(usingOperators, i);
+		else
+			opname = list_make1(makeString("="));
+
+		/* Now create the lvar <op> rvar join condition */
+		e = makeA_Expr(AEXPR_OP, opname,
+					   (Node *) copyObject(lvar), (Node *) copyObject(rvar),
+					   -1);
 
 		/* Prepare to combine into an AND clause, if multiple join columns */
 		andargs = lappend(andargs, e);
+		i++;
 	}
 
 	/* Only need an AND if there's more than one join column */
@@ -1542,10 +1559,25 @@ transformFromClauseItem(ParseState *pstate, Node *n,
 				res_colnames = lappend(res_colnames, lfirst(ucol));
 			}
 
+			/*
+			 * If an explicit USING ... OPERATOR (...) list was supplied, it
+			 * must provide exactly one operator per join column.  (NATURAL
+			 * joins can never reach here with a non-NIL usingOperators, since
+			 * the grammar does not allow the OPERATOR clause there.)
+			 */
+			if (j->usingOperators != NIL &&
+				list_length(j->usingOperators) != list_length(j->usingClause))
+				ereport(ERROR,
+						(errcode(ERRCODE_SYNTAX_ERROR),
+						 errmsg("number of JOIN/USING operators (%d) does not match number of columns (%d)",
+								list_length(j->usingOperators),
+								list_length(j->usingClause))));
+
 			/* Construct the generated JOIN ON clause */
 			j->quals = transformJoinUsingClause(pstate,
 												l_usingvars,
-												r_usingvars);
+												r_usingvars,
+												j->usingOperators);
 		}
 		else if (j->quals)
 		{
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index 8a9260fcd7..db154a1f63 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -520,6 +520,7 @@ static void get_from_clause(Query *query, const char *prefix,
 							deparse_context *context);
 static void get_from_clause_item(Node *jtnode, Query *query,
 								 deparse_context *context);
+static List *get_join_using_opexprs(JoinExpr *j, int nusing);
 static void get_rte_alias(RangeTblEntry *rte, int varno, bool use_as,
 						  deparse_context *context);
 static void get_column_alias_list(deparse_columns *colinfo,
@@ -543,6 +544,8 @@ static char *generate_function_name(Oid funcid, int nargs,
 									bool has_variadic, bool *use_variadic_p,
 									bool inGroupBy);
 static char *generate_operator_name(Oid operid, Oid arg1, Oid arg2);
+static char *generate_operator_name_extended(Oid operid, Oid arg1, Oid arg2,
+											 bool *needs_qual);
 static void add_cast_to(StringInfo buf, Oid typid);
 static char *generate_qualified_type_name(Oid typid);
 static text *string_to_text(char *str);
@@ -13014,6 +13017,43 @@ get_from_clause(Query *query, const char *prefix, deparse_context *context)
 	}
 }
 
+/*
+ * get_join_using_opexprs
+ *		Extract the per-column equality comparisons of a JOIN/USING clause
+ *		from its stored quals, as a list of OpExpr nodes.
+ *
+ * Returns NIL if the quals do not have the expected shape (e.g. a column's
+ * comparison acquired a coercion we don't recognize); callers must then
+ * fall back to undecorated output.
+ */
+static List *
+get_join_using_opexprs(JoinExpr *j, int nusing)
+{
+	Node	   *quals = j->quals;
+	List	   *result = NIL;
+	List	   *arms;
+	ListCell   *lc;
+
+	if (quals == NULL)
+		return NIL;
+	if (IsA(quals, BoolExpr) && ((BoolExpr *) quals)->boolop == AND_EXPR)
+		arms = ((BoolExpr *) quals)->args;
+	else
+		arms = list_make1(quals);
+	if (list_length(arms) != nusing)
+		return NIL;
+	foreach(lc, arms)
+	{
+		Node	   *arm = strip_implicit_coercions((Node *) lfirst(lc));
+
+		if (!IsA(arm, OpExpr) ||
+			list_length(((OpExpr *) arm)->args) != 2)
+			return NIL;
+		result = lappend(result, arm);
+	}
+	return result;
+}
+
 static void
 get_from_clause_item(Node *jtnode, Query *query, deparse_context *context)
 {
@@ -13265,6 +13305,9 @@ get_from_clause_item(Node *jtnode, Query *query, deparse_context *context)
 		{
 			ListCell   *lc;
 			bool		first = true;
+			List	   *opexprs;
+			List	   *opnames = NIL;
+			bool		need_op_list = false;
 
 			appendStringInfoString(buf, " USING (");
 			/* Use the assigned names, not what's in usingClause */
@@ -13280,6 +13323,49 @@ get_from_clause_item(Node *jtnode, Query *query, deparse_context *context)
 			}
 			appendStringInfoChar(buf, ')');
 
+			/*
+			 * A bare USING clause reparses each column's operator by the
+			 * unqualified name "=", so we must decorate the clause with an
+			 * explicit per-column operator list whenever that would not
+			 * recover the same operators.  This is the case if any column's
+			 * operator is not reachable by an unqualified lookup of its own
+			 * name (then it appears schema-qualified in the list), or is
+			 * named something other than "=" (then a bare clause would
+			 * wrongly resolve "="; it appears by its bare name in the list).
+			 * If we cannot recover the operators from the quals, print the
+			 * traditional undecorated form, as before.
+			 */
+			opexprs = get_join_using_opexprs(j,
+											 list_length(colinfo->usingNames));
+			foreach(lc, opexprs)
+			{
+				OpExpr	   *opexpr = (OpExpr *) lfirst(lc);
+				bool		needs_qual;
+				char	   *opname;
+
+				opname = generate_operator_name_extended(opexpr->opno,
+														 exprType(linitial(opexpr->args)),
+														 exprType(lsecond(opexpr->args)),
+														 &needs_qual);
+				opnames = lappend(opnames, opname);
+				need_op_list |= (needs_qual || strcmp(opname, "=") != 0);
+			}
+
+			if (need_op_list)
+			{
+				first = true;
+				appendStringInfoString(buf, " OPERATOR (");
+				foreach(lc, opnames)
+				{
+					if (first)
+						first = false;
+					else
+						appendStringInfoString(buf, ", ");
+					appendStringInfoString(buf, (char *) lfirst(lc));
+				}
+				appendStringInfoChar(buf, ')');
+			}
+
 			if (j->join_using_alias)
 				appendStringInfo(buf, " AS %s",
 								 quote_identifier(j->join_using_alias->aliasname));
@@ -14064,12 +14150,37 @@ generate_function_name(Oid funcid, int nargs, List *argnames, Oid *argtypes,
  */
 static char *
 generate_operator_name(Oid operid, Oid arg1, Oid arg2)
+{
+	bool		needs_qual;
+	char	   *oprname;
+
+	oprname = generate_operator_name_extended(operid, arg1, arg2,
+											  &needs_qual);
+	if (needs_qual)
+		oprname = psprintf("OPERATOR(%s)", oprname);
+
+	return oprname;
+}
+
+/*
+ * generate_operator_name_extended
+ *		Guts of generate_operator_name: compute the name to display for an
+ *		operator specified by OID, given the specified actual arg types.
+ *
+ * The result includes all necessary quoting and schema-prefixing, but not
+ * the OPERATOR() decoration that generate_operator_name would add; this
+ * form is suitable for constructs that take a bare (but possibly
+ * qualified) operator name, such as JOIN/USING's operator list.  Whether
+ * schema-prefixing was required is additionally reported in *needs_qual.
+ */
+static char *
+generate_operator_name_extended(Oid operid, Oid arg1, Oid arg2,
+								bool *needs_qual)
 {
 	StringInfoData buf;
 	HeapTuple	opertup;
 	Form_pg_operator operform;
 	char	   *oprname;
-	char	   *nspname;
 	Operator	p_result;
 
 	initStringInfo(&buf);
@@ -14102,18 +14213,17 @@ generate_operator_name(Oid operid, Oid arg1, Oid arg2)
 	}
 
 	if (p_result != NULL && oprid(p_result) == operid)
-		nspname = NULL;
+		*needs_qual = false;
 	else
 	{
-		nspname = get_namespace_name_or_temp(operform->oprnamespace);
-		appendStringInfo(&buf, "OPERATOR(%s.", quote_identifier(nspname));
+		char	   *nspname = get_namespace_name_or_temp(operform->oprnamespace);
+
+		*needs_qual = true;
+		appendStringInfo(&buf, "%s.", quote_identifier(nspname));
 	}
 
 	appendStringInfoString(&buf, oprname);
 
-	if (nspname)
-		appendStringInfoChar(&buf, ')');
-
 	if (p_result != NULL)
 		ReleaseSysCache(p_result);
 
diff --git a/src/include/catalog/catversion.h b/src/include/catalog/catversion.h
index 73ed6a0fa4..7571c5cd08 100644
--- a/src/include/catalog/catversion.h
+++ b/src/include/catalog/catversion.h
@@ -57,6 +57,6 @@
  */
 
 /*							yyyymmddN */
-#define CATALOG_VERSION_NO	202607022
+#define CATALOG_VERSION_NO	202607061
 
 #endif
diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h
index bb05aeebee..690d2976cd 100644
--- a/src/include/nodes/primnodes.h
+++ b/src/include/nodes/primnodes.h
@@ -2352,6 +2352,15 @@ typedef struct JoinExpr
 	Node	   *rarg;			/* right subtree */
 	/* USING clause, if any (list of String) */
 	List	   *usingClause pg_node_attr(query_jumble_ignore);
+
+	/*
+	 * USING ... OPERATOR (...): per-column possibly-qualified operator names
+	 * (list of String lists), or NIL for the implicit "=".  This is consulted
+	 * only during parse analysis to build the join quals; ruleutils.c
+	 * re-derives the operators from quals, so these raw names may go stale
+	 * harmlessly (e.g. after ALTER OPERATOR SET SCHEMA).
+	 */
+	List	   *usingOperators pg_node_attr(query_jumble_ignore);
 	/* alias attached to USING clause, if any */
 	Alias	   *join_using_alias pg_node_attr(query_jumble_ignore);
 	/* qualifiers on join, if any */
diff --git a/src/test/regress/expected/operator_qualify.out b/src/test/regress/expected/operator_qualify.out
index a812953b11..4fa8df62c4 100644
--- a/src/test/regress/expected/operator_qualify.out
+++ b/src/test/regress/expected/operator_qualify.out
@@ -248,3 +248,100 @@ WHERE tgqual LIKE '%:opno ' || 'alt_ops.=(int4,int4)'::regoperator::oid || ' %'
 -- are intentionally kept (not dropped): the pg_upgrade test suite
 -- dump/restores the regression database and thereby exercises the qualified
 -- deparse end-to-end.
+-- JOIN USING with an off-path equality operator
+CREATE TABLE oq_t2 (id int, a int);
+SET search_path = alt_ops, pg_catalog;
+CREATE VIEW public.oq_v_using AS
+  SELECT t.id FROM public.oq_t t JOIN public.oq_t2 u USING (id, a);
+CREATE VIEW public.oq_v_natural AS
+  SELECT t.id FROM public.oq_t t NATURAL JOIN public.oq_t2 u;
+-- Mixed list: pin id's operator to pg_catalog.= and a's to the shadow
+-- alt_ops.=.  Under the default path the former resolves bare while the
+-- latter must be qualified, so the emitted list is "OPERATOR (=, alt_ops.=)".
+CREATE VIEW public.oq_v_using_mixed AS
+  SELECT t.id FROM public.oq_t t JOIN public.oq_t2 u
+    USING (id, a) OPERATOR (pg_catalog.=, alt_ops.=);
+RESET search_path;
+-- A join operator whose name is not "=" (here pg_catalog.<, on the default
+-- path) must still force the list: a bare USING clause would reparse as "=".
+CREATE VIEW public.oq_v_using_lt AS
+  SELECT t.id FROM public.oq_t t JOIN public.oq_t2 u USING (id) OPERATOR (<);
+SELECT pg_get_viewdef('oq_v_using'::regclass, true);
+                          pg_get_viewdef                          
+------------------------------------------------------------------
+  SELECT t.id                                                    +
+    FROM oq_t t                                                  +
+      JOIN oq_t2 u USING (id, a) OPERATOR (alt_ops.=, alt_ops.=);
+(1 row)
+
+SELECT pg_get_viewdef('oq_v_natural'::regclass, true);
+                          pg_get_viewdef                          
+------------------------------------------------------------------
+  SELECT t.id                                                    +
+    FROM oq_t t                                                  +
+      JOIN oq_t2 u USING (id, a) OPERATOR (alt_ops.=, alt_ops.=);
+(1 row)
+
+SELECT pg_get_viewdef('oq_v_using_mixed'::regclass, true);
+                      pg_get_viewdef                      
+----------------------------------------------------------
+  SELECT t.id                                            +
+    FROM oq_t t                                          +
+      JOIN oq_t2 u USING (id, a) OPERATOR (=, alt_ops.=);
+(1 row)
+
+SELECT pg_get_viewdef('oq_v_using_lt'::regclass, true);
+               pg_get_viewdef               
+--------------------------------------------
+  SELECT t.id                              +
+    FROM oq_t t                            +
+      JOIN oq_t2 u USING (id) OPERATOR (<);
+(1 row)
+
+-- explicit syntax parses; mixed bare/qualified names allowed
+SELECT t.id FROM oq_t t JOIN oq_t2 u USING (id, a) OPERATOR (pg_catalog.=, alt_ops.=) WHERE false;
+ id 
+----
+(0 rows)
+
+SELECT t.id FROM oq_t t JOIN oq_t2 u USING (id, a) OPERATOR (=, alt_ops.=) AS j WHERE false;
+ id 
+----
+(0 rows)
+
+-- arity mismatch is an error
+SELECT t.id FROM oq_t t JOIN oq_t2 u USING (id, a) OPERATOR (alt_ops.=) WHERE false;
+ERROR:  number of JOIN/USING operators (1) does not match number of columns (2)
+-- reload round-trip under restricted search_path
+BEGIN; SET LOCAL search_path = pg_catalog;
+DO $$ BEGIN
+  EXECUTE 'CREATE VIEW public.oq_v_using_reload AS ' || pg_get_viewdef('public.oq_v_using'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_natural_reload AS ' || pg_get_viewdef('public.oq_v_natural'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_using_mixed_reload AS ' || pg_get_viewdef('public.oq_v_using_mixed'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_using_lt_reload AS ' || pg_get_viewdef('public.oq_v_using_lt'::regclass);
+END $$; COMMIT;
+-- Reloaded views resolve the same operator OIDs as the originals: alt_ops.=
+-- for oq_v_using/natural (both columns) and the mixed view (column a only).
+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_using_reload
+ oq_v_natural_reload
+ oq_v_using_mixed_reload
+(3 rows)
+
+-- The non-"=" operator OPERATOR(<) pins int4lt across the reload.
+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%reload'
+ORDER BY 1;
+       ev_class       
+----------------------
+ oq_v_using_lt_reload
+(1 row)
+
+DROP VIEW oq_v_using_reload, oq_v_natural_reload, oq_v_using_mixed_reload,
+          oq_v_using_lt_reload;
diff --git a/src/test/regress/sql/operator_qualify.sql b/src/test/regress/sql/operator_qualify.sql
index 41144ba867..c8faddddde 100644
--- a/src/test/regress/sql/operator_qualify.sql
+++ b/src/test/regress/sql/operator_qualify.sql
@@ -146,3 +146,52 @@ WHERE tgqual LIKE '%:opno ' || 'alt_ops.=(int4,int4)'::regoperator::oid || ' %'
 -- are intentionally kept (not dropped): the pg_upgrade test suite
 -- dump/restores the regression database and thereby exercises the qualified
 -- deparse end-to-end.
+
+-- JOIN USING with an off-path equality operator
+CREATE TABLE oq_t2 (id int, a int);
+SET search_path = alt_ops, pg_catalog;
+CREATE VIEW public.oq_v_using AS
+  SELECT t.id FROM public.oq_t t JOIN public.oq_t2 u USING (id, a);
+CREATE VIEW public.oq_v_natural AS
+  SELECT t.id FROM public.oq_t t NATURAL JOIN public.oq_t2 u;
+-- Mixed list: pin id's operator to pg_catalog.= and a's to the shadow
+-- alt_ops.=.  Under the default path the former resolves bare while the
+-- latter must be qualified, so the emitted list is "OPERATOR (=, alt_ops.=)".
+CREATE VIEW public.oq_v_using_mixed AS
+  SELECT t.id FROM public.oq_t t JOIN public.oq_t2 u
+    USING (id, a) OPERATOR (pg_catalog.=, alt_ops.=);
+RESET search_path;
+-- A join operator whose name is not "=" (here pg_catalog.<, on the default
+-- path) must still force the list: a bare USING clause would reparse as "=".
+CREATE VIEW public.oq_v_using_lt AS
+  SELECT t.id FROM public.oq_t t JOIN public.oq_t2 u USING (id) OPERATOR (<);
+SELECT pg_get_viewdef('oq_v_using'::regclass, true);
+SELECT pg_get_viewdef('oq_v_natural'::regclass, true);
+SELECT pg_get_viewdef('oq_v_using_mixed'::regclass, true);
+SELECT pg_get_viewdef('oq_v_using_lt'::regclass, true);
+-- explicit syntax parses; mixed bare/qualified names allowed
+SELECT t.id FROM oq_t t JOIN oq_t2 u USING (id, a) OPERATOR (pg_catalog.=, alt_ops.=) WHERE false;
+SELECT t.id FROM oq_t t JOIN oq_t2 u USING (id, a) OPERATOR (=, alt_ops.=) AS j WHERE false;
+-- arity mismatch is an error
+SELECT t.id FROM oq_t t JOIN oq_t2 u USING (id, a) OPERATOR (alt_ops.=) WHERE false;
+-- reload round-trip under restricted search_path
+BEGIN; SET LOCAL search_path = pg_catalog;
+DO $$ BEGIN
+  EXECUTE 'CREATE VIEW public.oq_v_using_reload AS ' || pg_get_viewdef('public.oq_v_using'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_natural_reload AS ' || pg_get_viewdef('public.oq_v_natural'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_using_mixed_reload AS ' || pg_get_viewdef('public.oq_v_using_mixed'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_using_lt_reload AS ' || pg_get_viewdef('public.oq_v_using_lt'::regclass);
+END $$; COMMIT;
+-- Reloaded views resolve the same operator OIDs as the originals: alt_ops.=
+-- for oq_v_using/natural (both columns) and the mixed view (column a only).
+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;
+-- The non-"=" operator OPERATOR(<) pins int4lt across the reload.
+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%reload'
+ORDER BY 1;
+DROP VIEW oq_v_using_reload, oq_v_natural_reload, oq_v_using_mixed_reload,
+          oq_v_using_lt_reload;
-- 
2.54.0

