v2-0001-Allow-schema-qualifying-the-operator-in-IS-DISTIN.patch
application/octet-stream
Filename: v2-0001-Allow-schema-qualifying-the-operator-in-IS-DISTIN.patch
Type: application/octet-stream
Part: 0
Patch
Format: format-patch
Series: patch v2-0001
Subject: Allow schema-qualifying the operator in IS DISTINCT FROM/NULLIF
| File | + | − |
|---|---|---|
| contrib/hstore/expected/hstore.out | 88 | 0 |
| contrib/hstore/sql/hstore.sql | 43 | 0 |
| doc/src/sgml/func/func-comparison.sgml | 19 | 4 |
| doc/src/sgml/func/func-conditional.sgml | 16 | 1 |
| doc/src/sgml/syntax.sgml | 11 | 0 |
| src/backend/parser/gram.y | 20 | 0 |
| src/backend/parser/parse_expr.c | 7 | 0 |
| src/backend/utils/adt/ruleutils.c | 49 | 1 |
| src/include/nodes/parsenodes.h | 6 | 3 |
| src/test/regress/expected/operator_qualify.out | 250 | 0 |
| src/test/regress/parallel_schedule | 1 | 1 |
| src/test/regress/sql/operator_qualify.sql | 148 | 0 |
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><></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 — for example one belonging
+ to an extension whose schema is not on the search path — 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 — for example one
+ belonging to an extension whose schema is not on the search path —
+ 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 — 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