From c48d226675d9cbcf569e5df4bccee18836b7963a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?= <michal.dtz@gmail.com>
Date: Tue, 7 Jul 2026 09:51:55 +0200
Subject: [PATCH v2 4/5] Allow per-column operator lists in ANY/ALL subquery
 comparisons

A multi-column subquery comparison such as (a, b) = ANY (SELECT ...) or
ROW(a, b) < (SELECT ...) resolves the written operator name independently
for each column pair, so the operator chosen for one column may live in a
different schema from the operator chosen for another.  ruleutils.c could
not reproduce that: it printed the first column's operator name for the
whole comparison and admitted as much --

    Note that we print the name of only the first operator, when there
    are multiple combining operators.  This is an approximation that
    could go wrong in various scenarios (operators in different schemas,
    renamed operators, etc) but there is not a whole lot we can do about
    it, since the syntax allows only one operator to be shown.

Worse, ANY_SUBLINK was displayed as IN whenever the first operator's name
resolved bare to "=", even if another column's equality operator was not
reachable that way; a dumped-and-reloaded view then silently reparsed
that column with a different operator -- changed semantics, no error.

Extend the previous commit's per-column operator list to subquery
comparisons,

    (a, b) OPERATOR(op1, op2) ANY (SELECT ...)

one operator per column of the test row, each independently
schema-qualifiable, accepted through both the subquery_Op grammar path
and transformAExprOp's conversion of "row op (subselect)" to a ROWCOMPARE
sublink.  transformSubLink now passes the list through to
make_row_comparison_op, which already resolves the n'th name against the
n'th column, so its temporary rejection goes away.

When deparsing, get_sublink_expr now collects every column's combining
operator from the testexpr (a single OpExpr, an AND/OR chain of OpExprs,
or a RowCompareExpr) and prints a single bare name only when that name
alone recovers every column's operator; otherwise it emits the explicit
OPERATOR(...) list.  IN is printed only when a plain IN -- which compares
with the bare name "=" -- would reparse to the very same operators.
Deparse output is unchanged whenever a single written name already
represented every column's operator exactly; in particular no expected
output outside the new tests changes.

Only the raw-parse representation changes shape -- SubLink.operName
already round-trips a nested list, stored rules keep the same
post-analysis testexpr, and nothing reads operName from stored trees --
so no catversion bump is required.

Discussion: https://postgr.es/m/3856834.1783087886@sss.pgh.pa.us
---
 doc/src/sgml/func/func-subquery.sgml          |  40 +++
 doc/src/sgml/syntax.sgml                      |   4 +-
 src/backend/parser/parse_expr.c               |  11 -
 src/backend/utils/adt/ruleutils.c             | 111 ++++++--
 src/include/nodes/primnodes.h                 |  10 +-
 .../regress/expected/operator_qualify.out     | 259 +++++++++++++++++-
 src/test/regress/sql/operator_qualify.sql     | 129 ++++++++-
 7 files changed, 524 insertions(+), 40 deletions(-)

diff --git a/doc/src/sgml/func/func-subquery.sgml b/doc/src/sgml/func/func-subquery.sgml
index f954f3bf13..eef0e8382f 100644
--- a/doc/src/sgml/func/func-subquery.sgml
+++ b/doc/src/sgml/func/func-subquery.sgml
@@ -263,6 +263,28 @@ WHERE EXISTS (SELECT 1 FROM tab2 WHERE col2 = tab1.col2);
    and at least one comparison returns NULL.
   </para>
 
+  <para>
+   As in a direct row-constructor comparison, the written
+   <replaceable>operator</replaceable>'s bare name is resolved independently
+   for each pair of columns.  You can instead pin the operator used for each
+   column by writing an explicit per-column operator list with the
+   <literal>OPERATOR()</literal> syntax (see
+   <xref linkend="sql-expressions-operator-calls"/>), giving one operator for
+   each column of the row:
+<synopsis>
+<replaceable>row_constructor</replaceable> OPERATOR(<replaceable>operator</replaceable> <optional>, <replaceable>operator</replaceable> ... </optional>) ANY (<replaceable>subquery</replaceable>)
+</synopsis>
+   The list must contain exactly one operator per column, and each operator
+   may be schema-qualified independently.
+   <productname>PostgreSQL</productname> itself falls back to this form when
+   deparsing a stored subquery comparison (for example in a view definition)
+   whose per-column operators cannot all be recovered from one written name.
+   In particular, a row-wise <token>IN</token> is displayed as
+   <literal>OPERATOR(...) ANY</literal> whenever a plain <token>IN</token>
+   would not resolve every column's equality operator to the operator
+   originally selected.
+  </para>
+
   <para>
    See <xref linkend="row-wise-comparison"/> for details about the meaning
    of a row constructor comparison.
@@ -319,6 +341,15 @@ WHERE EXISTS (SELECT 1 FROM tab2 WHERE col2 = tab1.col2);
    and at least one comparison returns NULL.
   </para>
 
+  <para>
+   The <replaceable>operator</replaceable> can also be written as an explicit
+   per-column operator list, exactly as described for <token>ANY</token>
+   (see <xref linkend="functions-subquery-any-some"/>):
+<synopsis>
+<replaceable>row_constructor</replaceable> OPERATOR(<replaceable>operator</replaceable> <optional>, <replaceable>operator</replaceable> ... </optional>) ALL (<replaceable>subquery</replaceable>)
+</synopsis>
+  </para>
+
   <para>
    See <xref linkend="row-wise-comparison"/> for details about the meaning
    of a row constructor comparison.
@@ -347,6 +378,15 @@ WHERE EXISTS (SELECT 1 FROM tab2 WHERE col2 = tab1.col2);
    compared row-wise to the single subquery result row.
   </para>
 
+  <para>
+   The <replaceable>operator</replaceable> can also be written as an explicit
+   per-column operator list, exactly as described for <token>ANY</token>
+   (see <xref linkend="functions-subquery-any-some"/>):
+<synopsis>
+<replaceable>row_constructor</replaceable> OPERATOR(<replaceable>operator</replaceable> <optional>, <replaceable>operator</replaceable> ... </optional>) (<replaceable>subquery</replaceable>)
+</synopsis>
+  </para>
+
   <para>
    See <xref linkend="row-wise-comparison"/> for details about the meaning
    of a row constructor comparison.
diff --git a/doc/src/sgml/syntax.sgml b/doc/src/sgml/syntax.sgml
index 094c2c2ffb..5165533813 100644
--- a/doc/src/sgml/syntax.sgml
+++ b/doc/src/sgml/syntax.sgml
@@ -1139,7 +1139,9 @@ SELECT 3 OPERATOR(pg_catalog.+) 4;
     (see <xref linkend="functions-nullif"/>).  A row-constructor comparison
     goes a step further and accepts a comma-separated <emphasis>list</emphasis>
     of operators inside <literal>OPERATOR()</literal>, one for each column of
-    the compared rows (see <xref linkend="row-wise-comparison"/>).
+    the compared rows (see <xref linkend="row-wise-comparison"/>); this
+    includes row-constructor comparisons against a subquery
+    (see <xref linkend="functions-subquery"/>).
    </para>
 
    <note>
diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c
index bd16e73a88..a8630e6c34 100644
--- a/src/backend/parser/parse_expr.c
+++ b/src/backend/parser/parse_expr.c
@@ -2020,17 +2020,6 @@ transformSubLink(ParseState *pstate, SubLink *sublink)
 		List	   *right_list;
 		ListCell   *l;
 
-		/*
-		 * TEMPORARY: reject a per-column operator list here.  A later patch
-		 * teaches this path (and ruleutils.c) to apply the n'th name to the
-		 * n'th column, at which point this check goes away.  Note this also
-		 * catches lists arriving via transformAExprOp's conversion of "row op
-		 * (subselect)" to a ROWCOMPARE sublink, which passes the A_Expr's
-		 * name through operName.
-		 */
-		reject_operator_name_list(pstate, sublink->operName,
-								  sublink->location);
-
 		/*
 		 * If the source was "x IN (select)", convert to "x = ANY (select)".
 		 */
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index 7cca15bbb5..487ee4b6e0 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -12526,7 +12526,11 @@ get_sublink_expr(SubLink *sublink, deparse_context *context)
 {
 	StringInfo	buf = context->buf;
 	Query	   *query = (Query *) (sublink->subselect);
+	List	   *opnos = NIL;
+	List	   *ltypes = NIL;
+	List	   *rtypes = NIL;
 	char	   *opname = NULL;
+	bool		all_bare_eq = true;
 	bool		need_paren;
 
 	if (sublink->subLinkType == ARRAY_SUBLINK)
@@ -12535,11 +12539,11 @@ get_sublink_expr(SubLink *sublink, deparse_context *context)
 		appendStringInfoChar(buf, '(');
 
 	/*
-	 * Note that we print the name of only the first operator, when there are
-	 * multiple combining operators.  This is an approximation that could go
-	 * wrong in various scenarios (operators in different schemas, renamed
-	 * operators, etc) but there is not a whole lot we can do about it, since
-	 * the syntax allows only one operator to be shown.
+	 * Dissect the testexpr into the per-column combining operators, printing
+	 * the lefthand expression(s) as we go.  Historically we printed only the
+	 * first operator's name for the whole comparison; now that the syntax
+	 * accepts an explicit per-column operator list we can represent the
+	 * combining operators exactly (see the emission rule below).
 	 */
 	if (sublink->testexpr)
 	{
@@ -12549,9 +12553,9 @@ get_sublink_expr(SubLink *sublink, deparse_context *context)
 			OpExpr	   *opexpr = (OpExpr *) sublink->testexpr;
 
 			get_rule_expr(linitial(opexpr->args), context, true);
-			opname = generate_operator_name(opexpr->opno,
-											exprType(linitial(opexpr->args)),
-											exprType(lsecond(opexpr->args)));
+			opnos = lappend_oid(opnos, opexpr->opno);
+			ltypes = lappend_oid(ltypes, exprType(linitial(opexpr->args)));
+			rtypes = lappend_oid(rtypes, exprType(lsecond(opexpr->args)));
 		}
 		else if (IsA(sublink->testexpr, BoolExpr))
 		{
@@ -12563,14 +12567,14 @@ get_sublink_expr(SubLink *sublink, deparse_context *context)
 			sep = "";
 			foreach(l, ((BoolExpr *) sublink->testexpr)->args)
 			{
-				OpExpr	   *opexpr = lfirst_node(OpExpr, l);
+				Node	   *arm = strip_implicit_coercions(lfirst(l));
+				OpExpr	   *opexpr = castNode(OpExpr, arm);
 
 				appendStringInfoString(buf, sep);
 				get_rule_expr(linitial(opexpr->args), context, true);
-				if (!opname)
-					opname = generate_operator_name(opexpr->opno,
-													exprType(linitial(opexpr->args)),
-													exprType(lsecond(opexpr->args)));
+				opnos = lappend_oid(opnos, opexpr->opno);
+				ltypes = lappend_oid(ltypes, exprType(linitial(opexpr->args)));
+				rtypes = lappend_oid(rtypes, exprType(lsecond(opexpr->args)));
 				sep = ", ";
 			}
 			appendStringInfoChar(buf, ')');
@@ -12579,12 +12583,20 @@ get_sublink_expr(SubLink *sublink, deparse_context *context)
 		{
 			/* multiple combining operators, < <= > >= cases */
 			RowCompareExpr *rcexpr = (RowCompareExpr *) sublink->testexpr;
+			ListCell   *opno_cell;
+			ListCell   *larg_cell;
+			ListCell   *rarg_cell;
 
 			appendStringInfoChar(buf, '(');
 			get_rule_expr((Node *) rcexpr->largs, context, true);
-			opname = generate_operator_name(linitial_oid(rcexpr->opnos),
-											exprType(linitial(rcexpr->largs)),
-											exprType(linitial(rcexpr->rargs)));
+			forthree(opno_cell, rcexpr->opnos,
+					 larg_cell, rcexpr->largs,
+					 rarg_cell, rcexpr->rargs)
+			{
+				opnos = lappend_oid(opnos, lfirst_oid(opno_cell));
+				ltypes = lappend_oid(ltypes, exprType((Node *) lfirst(larg_cell)));
+				rtypes = lappend_oid(rtypes, exprType((Node *) lfirst(rarg_cell)));
+			}
 			appendStringInfoChar(buf, ')');
 		}
 		else
@@ -12592,6 +12604,71 @@ get_sublink_expr(SubLink *sublink, deparse_context *context)
 				 (int) nodeTag(sublink->testexpr));
 	}
 
+	/*
+	 * Resolve the per-column operator names and decide how to spell the
+	 * combining operator(s).  The undecorated syntax writes one operator name
+	 * that reparses by resolving it independently for every column, so it is
+	 * honest only when a single bare name recovers each column's operator: no
+	 * column needs schema-qualification and every column shares the same
+	 * operator name.  In that case we print that shared name, as before.
+	 * Otherwise we emit an explicit per-column operator list, OPERATOR(op1,
+	 * op2, ...), which pins each column's operator independently.  (For one
+	 * column the list form degenerates to the familiar OPERATOR(op)
+	 * decoration.)  Likewise, ANY_SUBLINK is displayed as IN only if a plain
+	 * IN -- which compares with the bare name "=" -- would reparse to the
+	 * very same operators.
+	 */
+	if (opnos != NIL)
+	{
+		List	   *opnames = NIL;
+		bool		need_op_list = false;
+		char	   *firstname = NULL;
+		ListCell   *lc1;
+		ListCell   *lc2;
+		ListCell   *lc3;
+
+		forthree(lc1, opnos, lc2, ltypes, lc3, rtypes)
+		{
+			bool		needs_qual;
+			char	   *thisname;
+
+			thisname = generate_operator_name_extended(lfirst_oid(lc1),
+													   lfirst_oid(lc2),
+													   lfirst_oid(lc3),
+													   &needs_qual);
+			opnames = lappend(opnames, thisname);
+			if (needs_qual || strcmp(thisname, "=") != 0)
+				all_bare_eq = false;
+			if (firstname == NULL)
+				firstname = thisname;
+			else if (strcmp(firstname, thisname) != 0)
+				need_op_list = true;
+			if (needs_qual)
+				need_op_list = true;
+		}
+
+		if (need_op_list)
+		{
+			StringInfoData opbuf;
+			bool		first = true;
+
+			initStringInfo(&opbuf);
+			appendStringInfoString(&opbuf, "OPERATOR(");
+			foreach(lc1, opnames)
+			{
+				if (first)
+					first = false;
+				else
+					appendStringInfoString(&opbuf, ", ");
+				appendStringInfoString(&opbuf, (char *) lfirst(lc1));
+			}
+			appendStringInfoChar(&opbuf, ')');
+			opname = opbuf.data;
+		}
+		else
+			opname = firstname;
+	}
+
 	need_paren = true;
 
 	switch (sublink->subLinkType)
@@ -12601,7 +12678,7 @@ get_sublink_expr(SubLink *sublink, deparse_context *context)
 			break;
 
 		case ANY_SUBLINK:
-			if (strcmp(opname, "=") == 0)	/* Represent = ANY as IN */
+			if (all_bare_eq)	/* Represent = ANY as IN */
 				appendStringInfoString(buf, " IN ");
 			else
 				appendStringInfo(buf, " %s ANY ", opname);
diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h
index 690d2976cd..bf343cbec0 100644
--- a/src/include/nodes/primnodes.h
+++ b/src/include/nodes/primnodes.h
@@ -987,10 +987,12 @@ typedef struct BoolExpr
  * planning.
  *
  * NOTE: in the raw output of gram.y, testexpr contains just the raw form
- * of the lefthand expression (if any), and operName is the String name of
- * the combining operator.  Also, subselect is a raw parsetree.  During parse
- * analysis, the parser transforms testexpr into a complete boolean expression
- * that compares the lefthand value(s) to PARAM_SUBLINK nodes representing the
+ * of the lefthand expression (if any), and operName is the possibly-qualified
+ * name of the combining operator (a List of String), or a List of such names,
+ * one per column, when the comparison was written with an explicit per-column
+ * operator list.  Also, subselect is a raw parsetree.  During parse analysis,
+ * the parser transforms testexpr into a complete boolean expression that
+ * compares the lefthand value(s) to PARAM_SUBLINK nodes representing the
  * output columns of the subselect.  And subselect is transformed to a Query.
  * This is the representation seen in saved rules and in the rewriter.
  *
diff --git a/src/test/regress/expected/operator_qualify.out b/src/test/regress/expected/operator_qualify.out
index f6d70e886e..c0887e6311 100644
--- a/src/test/regress/expected/operator_qualify.out
+++ b/src/test/regress/expected/operator_qualify.out
@@ -444,7 +444,7 @@ ERROR:  number of operators (3) does not match number of columns (2) at characte
 -- Too few operators for the row width (two operators, three columns).
 SELECT ROW(1,2,3) OPERATOR(pg_catalog.<, pg_catalog.<) ROW(1,2,4);
 ERROR:  number of operators (2) does not match number of columns (3) at character 19
--- A list is only meaningful for row (and, later, subquery) comparisons.
+-- A list is only meaningful for row and subquery comparisons.
 -- Scalar infix rejects it.
 SELECT 1 OPERATOR(pg_catalog.=, pg_catalog.=) 2;
 ERROR:  a list of operators is only allowed in row and subquery comparisons at character 10
@@ -457,9 +457,6 @@ ERROR:  a list of operators is only allowed in row and subquery comparisons at c
 -- ORDER BY ... USING rejects it.
 SELECT 1 FROM public.oq_t ORDER BY a USING OPERATOR(pg_catalog.<, pg_catalog.<);
 ERROR:  a list of operators is only allowed in row and subquery comparisons at character 44
--- Sublinks reject operator lists in this patch (a later patch enables them).
-SELECT (1, 2) OPERATOR(pg_catalog.=, pg_catalog.=) ANY (SELECT 1, 2);
-ERROR:  a list of operators is only allowed in row and subquery comparisons at character 15
 -- DDL definition items (here a CREATE OPERATOR commutator) take a single
 -- operator name.  define.c has no ParseState, so this error carries no position.
 CREATE OPERATOR ### (leftarg = int4, rightarg = int4, procedure = int4eq,
@@ -501,3 +498,257 @@ DROP VIEW oq_v_rowcmp_mixed_reload;
 -- NOTE: oq_v_rowcmp and oq_v_rowcmp_mixed are intentionally kept (like the other
 -- oq_v_* views) so the pg_upgrade suite dump/restores them and exercises the
 -- qualified deparse.
+--
+-- Subquery comparisons: ANY/ALL and row-op-subquery sublinks accept the
+-- per-column operator list too, and their deparse represents the combining
+-- operators exactly (in particular, IN is printed only when a plain IN
+-- would reparse to the very same per-column operators).
+--
+-- A multi-column comparison must find a btree interpretation for every
+-- combining operator.  A "<>" is interpreted through its negator's btree
+-- equality membership, so link alt_ops.<> to alt_ops.= (strategy 3 of the
+-- operator class above) instead of extending the class.
+CREATE OPERATOR alt_ops.<> (leftarg = int4, rightarg = int4, procedure = int4ne,
+                            negator = OPERATOR(alt_ops.=));
+-- The list syntax is directly acceptable in all three sublink forms, with
+-- mixed bare/qualified names.
+SELECT (1, 2) OPERATOR(pg_catalog.=, pg_catalog.=) ANY (SELECT 1, 2) AS t;
+ t 
+---
+ t
+(1 row)
+
+SELECT (1, 2) OPERATOR(pg_catalog.=, alt_ops.=) ANY (SELECT 1, 2) AS t;
+ t 
+---
+ t
+(1 row)
+
+SELECT (1, 2) OPERATOR(alt_ops.<>, pg_catalog.<>) ALL (SELECT 2, 1) AS t;
+ t 
+---
+ t
+(1 row)
+
+-- "row op (subquery)" reaches the sublink through transformAExprOp's
+-- ROWCOMPARE conversion; the list flows through that path as well.
+SELECT (1, 2) OPERATOR(alt_ops.<, alt_ops.<) (SELECT 1, 3) AS t;
+ t 
+---
+ t
+(1 row)
+
+-- A one-element list is the traditional single-operator form (invariance
+-- guard: this line is accepted before the list grammar lands).
+SELECT (1, 2) OPERATOR(pg_catalog.=) ANY (SELECT 1, 2) AS t;
+ t 
+---
+ t
+(1 row)
+
+-- The operator count must match the row width.
+\set VERBOSITY terse
+SELECT (1, 2) OPERATOR(pg_catalog.=, pg_catalog.=, pg_catalog.=) ANY (SELECT 1, 2);
+ERROR:  number of operators (3) does not match number of columns (2) at character 15
+\set VERBOSITY default
+-- Views built while alt_ops shadows pg_catalog: every combining operator
+-- resolves into alt_ops.
+SET search_path = alt_ops, pg_catalog;
+CREATE VIEW public.oq_v_any AS
+  SELECT (a, b) = ANY (SELECT a, b FROM public.oq_t) AS r FROM public.oq_t;
+CREATE VIEW public.oq_v_in AS
+  SELECT (a, b) IN (SELECT a, b FROM public.oq_t) AS r FROM public.oq_t;
+CREATE VIEW public.oq_v_ne_all AS
+  SELECT (a, b) <> ALL (SELECT a, b FROM public.oq_t) AS r FROM public.oq_t;
+CREATE VIEW public.oq_v_rowcmp_sub AS
+  SELECT (a, b) < (SELECT a, b FROM public.oq_t LIMIT 1) AS r FROM public.oq_t;
+-- Single-column comparison: one off-path operator, no list needed.
+CREATE VIEW public.oq_v_any_scalar AS
+  SELECT a = ANY (SELECT b FROM public.oq_t) AS r FROM public.oq_t;
+-- An IN whose per-column "=" lookups land in *different* schemas: column 1
+-- compares int8 (alt_ops has no int8 operator, pg_catalog.= applies) while
+-- column 2 finds alt_ops.=.  Before this patch the view deparsed back to a
+-- plain IN, so a dump/reload under the default search_path silently replaced
+-- column 2's operator with pg_catalog.= -- changed semantics, no error.
+CREATE VIEW public.oq_v_in_mixed AS
+  SELECT (a::int8, b) IN (SELECT a::int8, b FROM public.oq_t) AS r FROM public.oq_t;
+RESET search_path;
+-- A mixed explicit list, written under the default path.
+CREATE VIEW public.oq_v_any_mixed AS
+  SELECT (a, b) OPERATOR(pg_catalog.=, alt_ops.=) ANY (SELECT a, b FROM public.oq_t) AS r
+  FROM public.oq_t;
+-- Guards: IN over pg_catalog operators only must keep printing IN.
+CREATE VIEW public.oq_v_in_cat AS
+  SELECT (a, b) IN (SELECT a, b FROM public.oq_t) AS r FROM public.oq_t;
+CREATE VIEW public.oq_v_in_scalar_cat AS
+  SELECT a IN (SELECT b FROM public.oq_t) AS r FROM public.oq_t;
+-- Off the path, IN may not be printed: a plain IN would reparse every column
+-- with pg_catalog.=.  The honest form is the per-column operator list.
+SELECT pg_get_viewdef('oq_v_any'::regclass, true);
+                            pg_get_viewdef                             
+-----------------------------------------------------------------------
+  SELECT ((a, b) OPERATOR(alt_ops.=, alt_ops.=) ANY ( SELECT oq_t_1.a,+
+             oq_t_1.b                                                 +
+            FROM oq_t oq_t_1)) AS r                                   +
+    FROM oq_t;
+(1 row)
+
+SELECT pg_get_viewdef('oq_v_in'::regclass, true);
+                            pg_get_viewdef                             
+-----------------------------------------------------------------------
+  SELECT ((a, b) OPERATOR(alt_ops.=, alt_ops.=) ANY ( SELECT oq_t_1.a,+
+             oq_t_1.b                                                 +
+            FROM oq_t oq_t_1)) AS r                                   +
+    FROM oq_t;
+(1 row)
+
+SELECT pg_get_viewdef('oq_v_ne_all'::regclass, true);
+                             pg_get_viewdef                              
+-------------------------------------------------------------------------
+  SELECT ((a, b) OPERATOR(alt_ops.<>, alt_ops.<>) ALL ( SELECT oq_t_1.a,+
+             oq_t_1.b                                                   +
+            FROM oq_t oq_t_1)) AS r                                     +
+    FROM oq_t;
+(1 row)
+
+SELECT pg_get_viewdef('oq_v_rowcmp_sub'::regclass, true);
+                          pg_get_viewdef                           
+-------------------------------------------------------------------
+  SELECT ((a, b) OPERATOR(alt_ops.<, alt_ops.<) ( SELECT oq_t_1.a,+
+             oq_t_1.b                                             +
+            FROM oq_t oq_t_1                                      +
+          LIMIT 1)) AS r                                          +
+    FROM oq_t;
+(1 row)
+
+-- The single-column form needs no list, just the qualified operator.
+SELECT pg_get_viewdef('oq_v_any_scalar'::regclass, true);
+                    pg_get_viewdef                    
+------------------------------------------------------
+  SELECT (a OPERATOR(alt_ops.=) ANY ( SELECT oq_t_1.b+
+            FROM oq_t oq_t_1)) AS r                  +
+    FROM oq_t;
+(1 row)
+
+-- The mixed views print a list of one bare and one qualified name.
+SELECT pg_get_viewdef('oq_v_in_mixed'::regclass, true);
+                                   pg_get_viewdef                                   
+------------------------------------------------------------------------------------
+  SELECT ((a::bigint, b) OPERATOR(=, alt_ops.=) ANY ( SELECT oq_t_1.a::bigint AS a,+
+             oq_t_1.b                                                              +
+            FROM oq_t oq_t_1)) AS r                                                +
+    FROM oq_t;
+(1 row)
+
+SELECT pg_get_viewdef('oq_v_any_mixed'::regclass, true);
+                        pg_get_viewdef                         
+---------------------------------------------------------------
+  SELECT ((a, b) OPERATOR(=, alt_ops.=) ANY ( SELECT oq_t_1.a,+
+             oq_t_1.b                                         +
+            FROM oq_t oq_t_1)) AS r                           +
+    FROM oq_t;
+(1 row)
+
+-- The pg_catalog-only views still print IN.
+SELECT pg_get_viewdef('oq_v_in_cat'::regclass, true);
+            pg_get_viewdef             
+---------------------------------------
+  SELECT ((a, b) IN ( SELECT oq_t_1.a,+
+             oq_t_1.b                 +
+            FROM oq_t oq_t_1)) AS r   +
+    FROM oq_t;
+(1 row)
+
+SELECT pg_get_viewdef('oq_v_in_scalar_cat'::regclass, true);
+           pg_get_viewdef           
+------------------------------------
+  SELECT (a IN ( SELECT oq_t_1.b   +
+            FROM oq_t oq_t_1)) AS r+
+    FROM oq_t;
+(1 row)
+
+-- With alt_ops reachable again, every column's operator resolves bare to "="
+-- and the readable IN comes back.
+SET search_path = alt_ops, pg_catalog, public;
+SELECT pg_get_viewdef('oq_v_in'::regclass, true);
+            pg_get_viewdef             
+---------------------------------------
+  SELECT ((a, b) IN ( SELECT oq_t_1.a,+
+             oq_t_1.b                 +
+            FROM oq_t oq_t_1)) AS r   +
+    FROM oq_t;
+(1 row)
+
+RESET search_path;
+-- The deparsed sublinks must reparse under a restricted search_path (the
+-- pg_dump scenario), pinning the same operator OIDs as the originals.
+BEGIN; SET LOCAL search_path = pg_catalog;
+DO $$ BEGIN
+  EXECUTE 'CREATE VIEW public.oq_v_any_reload AS ' || pg_get_viewdef('public.oq_v_any'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_in_reload AS ' || pg_get_viewdef('public.oq_v_in'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_ne_all_reload AS ' || pg_get_viewdef('public.oq_v_ne_all'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_rowcmp_sub_reload AS ' || pg_get_viewdef('public.oq_v_rowcmp_sub'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_any_scalar_reload AS ' || pg_get_viewdef('public.oq_v_any_scalar'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_in_mixed_reload AS ' || pg_get_viewdef('public.oq_v_in_mixed'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_any_mixed_reload AS ' || pg_get_viewdef('public.oq_v_any_mixed'::regclass);
+END $$; COMMIT;
+-- Every reloaded view that pinned alt_ops.= still pins it (the = and <>
+-- forms decompose into per-column OpExprs, serialized with ":opno").
+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_any_reload
+ oq_v_in_reload
+ oq_v_any_scalar_reload
+ oq_v_in_mixed_reload
+ oq_v_any_mixed_reload
+(5 rows)
+
+-- The mixed views pin pg_catalog's "=" for column 1 *followed by* alt_ops.=
+-- for column 2: the combining OpExprs are serialized in column order.
+SELECT ev_class::regclass FROM pg_rewrite
+WHERE ev_action LIKE '%:opno ' || 'pg_catalog.=(int8,int8)'::regoperator::oid
+                  || ' %:opno ' || 'alt_ops.=(int4,int4)'::regoperator::oid || ' %'
+  AND ev_class::regclass::text = 'oq_v_in_mixed_reload';
+       ev_class       
+----------------------
+ oq_v_in_mixed_reload
+(1 row)
+
+SELECT ev_class::regclass FROM pg_rewrite
+WHERE ev_action LIKE '%:opno ' || 'pg_catalog.=(int4,int4)'::regoperator::oid
+                  || ' %:opno ' || 'alt_ops.=(int4,int4)'::regoperator::oid || ' %'
+  AND ev_class::regclass::text = 'oq_v_any_mixed_reload';
+       ev_class        
+-----------------------
+ oq_v_any_mixed_reload
+(1 row)
+
+-- <> ALL keeps alt_ops.<> ...
+SELECT ev_class::regclass FROM pg_rewrite
+WHERE ev_action LIKE '%:opno ' || 'alt_ops.<>(int4,int4)'::regoperator::oid || ' %'
+  AND ev_class::regclass::text = 'oq_v_ne_all_reload';
+      ev_class      
+--------------------
+ oq_v_ne_all_reload
+(1 row)
+
+-- ... and the row comparison keeps alt_ops.< for both columns (a sublink's
+-- RowCompareExpr serializes its operators as one oid list "(o ...)").
+SELECT ev_class::regclass FROM pg_rewrite
+WHERE ev_action LIKE '%(o ' || 'alt_ops.<(int4,int4)'::regoperator::oid
+                   || ' ' || 'alt_ops.<(int4,int4)'::regoperator::oid || '%'
+  AND ev_class::regclass::text = 'oq_v_rowcmp_sub_reload';
+        ev_class        
+------------------------
+ oq_v_rowcmp_sub_reload
+(1 row)
+
+DROP VIEW oq_v_any_reload, oq_v_in_reload, oq_v_ne_all_reload,
+          oq_v_rowcmp_sub_reload, oq_v_any_scalar_reload,
+          oq_v_in_mixed_reload, oq_v_any_mixed_reload;
+-- NOTE: the new oq_v_* base views are intentionally kept (like the others)
+-- so the pg_upgrade suite dump/restores them and exercises the deparse.
diff --git a/src/test/regress/sql/operator_qualify.sql b/src/test/regress/sql/operator_qualify.sql
index 74a9428d43..f029d0eb73 100644
--- a/src/test/regress/sql/operator_qualify.sql
+++ b/src/test/regress/sql/operator_qualify.sql
@@ -259,7 +259,7 @@ SELECT ROW(1,2) OPERATOR(pg_catalog.=, alt_ops.=) ROW(1,2) AS t;
 SELECT ROW(1,2) OPERATOR(pg_catalog.<, pg_catalog.<, pg_catalog.<) ROW(2,1);
 -- Too few operators for the row width (two operators, three columns).
 SELECT ROW(1,2,3) OPERATOR(pg_catalog.<, pg_catalog.<) ROW(1,2,4);
--- A list is only meaningful for row (and, later, subquery) comparisons.
+-- A list is only meaningful for row and subquery comparisons.
 -- Scalar infix rejects it.
 SELECT 1 OPERATOR(pg_catalog.=, pg_catalog.=) 2;
 -- Scalar prefix rejects it too.
@@ -268,8 +268,6 @@ SELECT OPERATOR(pg_catalog.-, pg_catalog.-) 1;
 SELECT 1 OPERATOR(pg_catalog.=, pg_catalog.=) ANY (ARRAY[1,2]);
 -- ORDER BY ... USING rejects it.
 SELECT 1 FROM public.oq_t ORDER BY a USING OPERATOR(pg_catalog.<, pg_catalog.<);
--- Sublinks reject operator lists in this patch (a later patch enables them).
-SELECT (1, 2) OPERATOR(pg_catalog.=, pg_catalog.=) ANY (SELECT 1, 2);
 -- DDL definition items (here a CREATE OPERATOR commutator) take a single
 -- operator name.  define.c has no ParseState, so this error carries no position.
 CREATE OPERATOR ### (leftarg = int4, rightarg = int4, procedure = int4eq,
@@ -301,3 +299,128 @@ DROP VIEW oq_v_rowcmp_mixed_reload;
 -- NOTE: oq_v_rowcmp and oq_v_rowcmp_mixed are intentionally kept (like the other
 -- oq_v_* views) so the pg_upgrade suite dump/restores them and exercises the
 -- qualified deparse.
+
+--
+-- Subquery comparisons: ANY/ALL and row-op-subquery sublinks accept the
+-- per-column operator list too, and their deparse represents the combining
+-- operators exactly (in particular, IN is printed only when a plain IN
+-- would reparse to the very same per-column operators).
+--
+-- A multi-column comparison must find a btree interpretation for every
+-- combining operator.  A "<>" is interpreted through its negator's btree
+-- equality membership, so link alt_ops.<> to alt_ops.= (strategy 3 of the
+-- operator class above) instead of extending the class.
+CREATE OPERATOR alt_ops.<> (leftarg = int4, rightarg = int4, procedure = int4ne,
+                            negator = OPERATOR(alt_ops.=));
+
+-- The list syntax is directly acceptable in all three sublink forms, with
+-- mixed bare/qualified names.
+SELECT (1, 2) OPERATOR(pg_catalog.=, pg_catalog.=) ANY (SELECT 1, 2) AS t;
+SELECT (1, 2) OPERATOR(pg_catalog.=, alt_ops.=) ANY (SELECT 1, 2) AS t;
+SELECT (1, 2) OPERATOR(alt_ops.<>, pg_catalog.<>) ALL (SELECT 2, 1) AS t;
+-- "row op (subquery)" reaches the sublink through transformAExprOp's
+-- ROWCOMPARE conversion; the list flows through that path as well.
+SELECT (1, 2) OPERATOR(alt_ops.<, alt_ops.<) (SELECT 1, 3) AS t;
+-- A one-element list is the traditional single-operator form (invariance
+-- guard: this line is accepted before the list grammar lands).
+SELECT (1, 2) OPERATOR(pg_catalog.=) ANY (SELECT 1, 2) AS t;
+-- The operator count must match the row width.
+\set VERBOSITY terse
+SELECT (1, 2) OPERATOR(pg_catalog.=, pg_catalog.=, pg_catalog.=) ANY (SELECT 1, 2);
+\set VERBOSITY default
+
+-- Views built while alt_ops shadows pg_catalog: every combining operator
+-- resolves into alt_ops.
+SET search_path = alt_ops, pg_catalog;
+CREATE VIEW public.oq_v_any AS
+  SELECT (a, b) = ANY (SELECT a, b FROM public.oq_t) AS r FROM public.oq_t;
+CREATE VIEW public.oq_v_in AS
+  SELECT (a, b) IN (SELECT a, b FROM public.oq_t) AS r FROM public.oq_t;
+CREATE VIEW public.oq_v_ne_all AS
+  SELECT (a, b) <> ALL (SELECT a, b FROM public.oq_t) AS r FROM public.oq_t;
+CREATE VIEW public.oq_v_rowcmp_sub AS
+  SELECT (a, b) < (SELECT a, b FROM public.oq_t LIMIT 1) AS r FROM public.oq_t;
+-- Single-column comparison: one off-path operator, no list needed.
+CREATE VIEW public.oq_v_any_scalar AS
+  SELECT a = ANY (SELECT b FROM public.oq_t) AS r FROM public.oq_t;
+-- An IN whose per-column "=" lookups land in *different* schemas: column 1
+-- compares int8 (alt_ops has no int8 operator, pg_catalog.= applies) while
+-- column 2 finds alt_ops.=.  Before this patch the view deparsed back to a
+-- plain IN, so a dump/reload under the default search_path silently replaced
+-- column 2's operator with pg_catalog.= -- changed semantics, no error.
+CREATE VIEW public.oq_v_in_mixed AS
+  SELECT (a::int8, b) IN (SELECT a::int8, b FROM public.oq_t) AS r FROM public.oq_t;
+RESET search_path;
+-- A mixed explicit list, written under the default path.
+CREATE VIEW public.oq_v_any_mixed AS
+  SELECT (a, b) OPERATOR(pg_catalog.=, alt_ops.=) ANY (SELECT a, b FROM public.oq_t) AS r
+  FROM public.oq_t;
+-- Guards: IN over pg_catalog operators only must keep printing IN.
+CREATE VIEW public.oq_v_in_cat AS
+  SELECT (a, b) IN (SELECT a, b FROM public.oq_t) AS r FROM public.oq_t;
+CREATE VIEW public.oq_v_in_scalar_cat AS
+  SELECT a IN (SELECT b FROM public.oq_t) AS r FROM public.oq_t;
+
+-- Off the path, IN may not be printed: a plain IN would reparse every column
+-- with pg_catalog.=.  The honest form is the per-column operator list.
+SELECT pg_get_viewdef('oq_v_any'::regclass, true);
+SELECT pg_get_viewdef('oq_v_in'::regclass, true);
+SELECT pg_get_viewdef('oq_v_ne_all'::regclass, true);
+SELECT pg_get_viewdef('oq_v_rowcmp_sub'::regclass, true);
+-- The single-column form needs no list, just the qualified operator.
+SELECT pg_get_viewdef('oq_v_any_scalar'::regclass, true);
+-- The mixed views print a list of one bare and one qualified name.
+SELECT pg_get_viewdef('oq_v_in_mixed'::regclass, true);
+SELECT pg_get_viewdef('oq_v_any_mixed'::regclass, true);
+-- The pg_catalog-only views still print IN.
+SELECT pg_get_viewdef('oq_v_in_cat'::regclass, true);
+SELECT pg_get_viewdef('oq_v_in_scalar_cat'::regclass, true);
+-- With alt_ops reachable again, every column's operator resolves bare to "="
+-- and the readable IN comes back.
+SET search_path = alt_ops, pg_catalog, public;
+SELECT pg_get_viewdef('oq_v_in'::regclass, true);
+RESET search_path;
+
+-- The deparsed sublinks must reparse under a restricted search_path (the
+-- pg_dump scenario), pinning the same operator OIDs as the originals.
+BEGIN; SET LOCAL search_path = pg_catalog;
+DO $$ BEGIN
+  EXECUTE 'CREATE VIEW public.oq_v_any_reload AS ' || pg_get_viewdef('public.oq_v_any'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_in_reload AS ' || pg_get_viewdef('public.oq_v_in'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_ne_all_reload AS ' || pg_get_viewdef('public.oq_v_ne_all'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_rowcmp_sub_reload AS ' || pg_get_viewdef('public.oq_v_rowcmp_sub'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_any_scalar_reload AS ' || pg_get_viewdef('public.oq_v_any_scalar'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_in_mixed_reload AS ' || pg_get_viewdef('public.oq_v_in_mixed'::regclass);
+  EXECUTE 'CREATE VIEW public.oq_v_any_mixed_reload AS ' || pg_get_viewdef('public.oq_v_any_mixed'::regclass);
+END $$; COMMIT;
+-- Every reloaded view that pinned alt_ops.= still pins it (the = and <>
+-- forms decompose into per-column OpExprs, serialized with ":opno").
+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 mixed views pin pg_catalog's "=" for column 1 *followed by* alt_ops.=
+-- for column 2: the combining OpExprs are serialized in column order.
+SELECT ev_class::regclass FROM pg_rewrite
+WHERE ev_action LIKE '%:opno ' || 'pg_catalog.=(int8,int8)'::regoperator::oid
+                  || ' %:opno ' || 'alt_ops.=(int4,int4)'::regoperator::oid || ' %'
+  AND ev_class::regclass::text = 'oq_v_in_mixed_reload';
+SELECT ev_class::regclass FROM pg_rewrite
+WHERE ev_action LIKE '%:opno ' || 'pg_catalog.=(int4,int4)'::regoperator::oid
+                  || ' %:opno ' || 'alt_ops.=(int4,int4)'::regoperator::oid || ' %'
+  AND ev_class::regclass::text = 'oq_v_any_mixed_reload';
+-- <> ALL keeps alt_ops.<> ...
+SELECT ev_class::regclass FROM pg_rewrite
+WHERE ev_action LIKE '%:opno ' || 'alt_ops.<>(int4,int4)'::regoperator::oid || ' %'
+  AND ev_class::regclass::text = 'oq_v_ne_all_reload';
+-- ... and the row comparison keeps alt_ops.< for both columns (a sublink's
+-- RowCompareExpr serializes its operators as one oid list "(o ...)").
+SELECT ev_class::regclass FROM pg_rewrite
+WHERE ev_action LIKE '%(o ' || 'alt_ops.<(int4,int4)'::regoperator::oid
+                   || ' ' || 'alt_ops.<(int4,int4)'::regoperator::oid || '%'
+  AND ev_class::regclass::text = 'oq_v_rowcmp_sub_reload';
+DROP VIEW oq_v_any_reload, oq_v_in_reload, oq_v_ne_all_reload,
+          oq_v_rowcmp_sub_reload, oq_v_any_scalar_reload,
+          oq_v_in_mixed_reload, oq_v_any_mixed_reload;
+-- NOTE: the new oq_v_* base views are intentionally kept (like the others)
+-- so the pg_upgrade suite dump/restores them and exercises the deparse.
-- 
2.54.0

