From d107fa4d60d61bddb5c249d29641f657274f7fd2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?= <michal.dtz@gmail.com>
Date: Mon, 29 Jun 2026 14:00:54 +0200
Subject: [PATCH v1] Schema-qualify the equality operator when deparsing
 NULLIF/IS DISTINCT FROM

The NULLIF and IS DISTINCT FROM constructs use an equality operator that
their SQL syntax identifies only by the unqualified name "="; the operator
is re-resolved against the prevailing search_path when a dumped rule or view
definition is reloaded.  Unlike a plain OpExpr, which ruleutils.c deparses
using the OPERATOR(schema.op) notation, these constructs have nowhere to
write a schema-qualified operator name.  As a result a view that applies
NULLIF or IS [NOT] DISTINCT FROM to values whose "=" operator is not on the
search_path (for example an hstore column) could not be dumped and reloaded:
pg_dump reloads with an empty search_path, so the reload failed with
"operator does not exist: ... = ...".

Fix this in the deparser.  When the equality operator would not be found by
its bare name under the current search_path (detected via
generate_operator_name, which already decides when the OPERATOR(...)
decoration is required), emit an equivalent expression that can carry the
qualified operator instead of the normal syntax:

  NULLIF(a, b)          ->  CASE WHEN a IS NOT NULL AND b IS NOT NULL
                                 AND (a OPERATOR(s.=) b) THEN NULL ELSE a END
  a IS DISTINCT FROM b  ->  ((a IS NOT NULL OR b IS NOT NULL)
                             AND (a IS NULL OR b IS NULL
                                  OR NOT (a OPERATOR(s.=) b)))

The DISTINCT substitute is always parenthesized so an enclosing NOT (as in
IS NOT DISTINCT FROM) binds correctly; CASE ... END is self-delimiting.
Both reproduce the executor's semantics exactly, including NULLIF's single
treatment of null inputs and DistinctExpr yielding NULL when "=" yields NULL
for two non-null inputs.

The substitute forms evaluate their inputs more than once, whereas NULLIF
and DistinctExpr evaluate each input exactly once, so they are used only
when the inputs contain no volatile functions; otherwise the original syntax
is kept.  When no qualification is needed the deparsed output is unchanged.

A view whose volatile input uses an off-search-path operator still cannot be
reloaded; fully handling that would require carrying the operator through
the constructs' grammar, which this patch does not attempt.

Add regression coverage to contrib/hstore.
---
 contrib/hstore/expected/hstore.out |  85 ++++++++++++++++++
 contrib/hstore/sql/hstore.sql      |  40 +++++++++
 src/backend/utils/adt/ruleutils.c  | 134 +++++++++++++++++++++++++++--
 3 files changed, 250 insertions(+), 9 deletions(-)

diff --git a/contrib/hstore/expected/hstore.out b/contrib/hstore/expected/hstore.out
index acea880..b433e23 100644
--- a/contrib/hstore/expected/hstore.out
+++ b/contrib/hstore/expected/hstore.out
@@ -1632,3 +1632,88 @@ 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 empty 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 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 hstore off the search_path the operator must be schema-qualified.
+SET search_path = hstore_view_test;
+SELECT pg_get_viewdef('hstore_view_test.v_nullif'::regclass, true);
+                                              pg_get_viewdef                                               
+-----------------------------------------------------------------------------------------------------------
+  SELECT id,                                                                                              +
+     CASE WHEN c1 IS NOT NULL AND c2 IS NOT NULL AND (c1 OPERATOR(public.=) c2) THEN NULL ELSE c1 END AS r+
+    FROM t;
+(1 row)
+
+SELECT pg_get_viewdef('hstore_view_test.v_distinct'::regclass, true);
+                                                 pg_get_viewdef                                                 
+----------------------------------------------------------------------------------------------------------------
+  SELECT id,                                                                                                   +
+     ((c1 IS NOT NULL OR c2 IS NOT NULL) AND (c1 IS NULL OR c2 IS NULL OR NOT (c1 OPERATOR(public.=) 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 NOT NULL OR c2 IS NOT NULL) AND (c1 IS NULL OR c2 IS NULL OR NOT (c1 OPERATOR(public.=) 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 8ae95e8..60df633 100644
--- a/contrib/hstore/sql/hstore.sql
+++ b/contrib/hstore/sql/hstore.sql
@@ -393,3 +393,43 @@ 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 empty 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 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 hstore off the search_path the operator must be schema-qualified.
+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/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index 88de5c0..0757c40 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -543,6 +543,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 bool nullif_distinct_needs_qualified_form(Oid opno, Node *arg1,
+												 Node *arg2, char **opname);
 static void add_cast_to(StringInfo buf, Oid typid);
 static char *generate_qualified_type_name(Oid typid);
 static text *string_to_text(char *str);
@@ -9800,6 +9802,52 @@ get_json_expr_options(JsonExpr *jsexpr, deparse_context *context,
 		get_json_behavior(jsexpr->on_error, context, "ERROR");
 }
 
+/*
+ * nullif_distinct_needs_qualified_form
+ *
+ * Decide whether a NULLIF or IS [NOT] DISTINCT FROM construct has to be
+ * deparsed using an explicit, operator-qualified substitute expression
+ * instead of its normal syntax.
+ *
+ * Both constructs depend on an equality operator that is written, in their
+ * syntax, only as the unqualified name "=" --- the operator is re-resolved
+ * against the prevailing search_path when the dumped definition is reloaded.
+ * That fails when the operator would not be found by bare name, for example an
+ * extension's "=" operator in a view dumped with an empty search_path: the
+ * NULLIF / IS DISTINCT FROM syntax has nowhere to write a schema-qualified
+ * operator name (unlike a plain OpExpr, which can use OPERATOR(schema.=)).
+ *
+ * generate_operator_name() already schema-qualifies an operator (emitting the
+ * "OPERATOR(...)" decoration) exactly when the bare name would not resolve to
+ * the same operator under the current search_path, so we reuse it as the test.
+ * When qualification is needed, the caller emits an equivalent CASE / boolean
+ * expression that can carry the decoration; *opname returns the qualified name
+ * for it to use.  Those substitute forms evaluate their input expressions more
+ * than once, so we only use them when the inputs are free of volatile
+ * functions; otherwise *opname is set to NULL and the caller falls back to the
+ * standard syntax (which still reloads correctly whenever the operator is on
+ * the search_path).
+ */
+static bool
+nullif_distinct_needs_qualified_form(Oid opno, Node *arg1, Node *arg2,
+									 char **opname)
+{
+	char	   *name;
+
+	*opname = NULL;
+
+	name = generate_operator_name(opno, exprType(arg1), exprType(arg2));
+	if (strncmp(name, "OPERATOR(", 9) != 0)
+		return false;			/* bare "=" resolves correctly; nothing to do */
+
+	/* the substitute forms evaluate arg1/arg2 more than once */
+	if (contain_volatile_functions(arg1) || contain_volatile_functions(arg2))
+		return false;
+
+	*opname = name;
+	return true;
+}
+
 /* ----------
  * get_rule_expr			- Parse back an expression
  *
@@ -9958,24 +10006,92 @@ get_rule_expr(Node *node, deparse_context *context,
 				List	   *args = expr->args;
 				Node	   *arg1 = (Node *) linitial(args);
 				Node	   *arg2 = (Node *) lsecond(args);
+				char	   *opname;
 
-				if (!PRETTY_PAREN(context))
+				if (nullif_distinct_needs_qualified_form(expr->opno,
+														 arg1, arg2, &opname))
+				{
+					/*
+					 * The equality operator is not visible by its bare name
+					 * under the prevailing search_path, but IS DISTINCT FROM
+					 * syntax cannot carry a qualified operator name.  Emit
+					 * instead the equivalent boolean expression "(a IS NOT
+					 * NULL OR b IS NOT NULL) AND (a IS NULL OR b IS NULL OR
+					 * NOT (a = b))" using OPERATOR(schema.=).  This
+					 * reproduces the executor's DistinctExpr semantics,
+					 * including yielding NULL when "=" yields NULL for two
+					 * non-null inputs.  The result is always parenthesized so
+					 * that an enclosing NOT (as in IS NOT DISTINCT FROM)
+					 * binds it correctly.
+					 */
 					appendStringInfoChar(buf, '(');
-				get_rule_expr_paren(arg1, context, true, node);
-				appendStringInfoString(buf, " IS DISTINCT FROM ");
-				get_rule_expr_paren(arg2, context, true, node);
-				if (!PRETTY_PAREN(context))
-					appendStringInfoChar(buf, ')');
+					appendStringInfoChar(buf, '(');
+					get_rule_expr_paren(arg1, context, true, node);
+					appendStringInfoString(buf, " IS NOT NULL OR ");
+					get_rule_expr_paren(arg2, context, true, node);
+					appendStringInfoString(buf, " IS NOT NULL) AND (");
+					get_rule_expr_paren(arg1, context, true, node);
+					appendStringInfoString(buf, " IS NULL OR ");
+					get_rule_expr_paren(arg2, context, true, node);
+					appendStringInfoString(buf, " IS NULL OR NOT (");
+					get_rule_expr_paren(arg1, context, true, node);
+					appendStringInfo(buf, " %s ", opname);
+					get_rule_expr_paren(arg2, context, true, node);
+					appendStringInfoString(buf, ")))");
+				}
+				else
+				{
+					if (!PRETTY_PAREN(context))
+						appendStringInfoChar(buf, '(');
+					get_rule_expr_paren(arg1, context, true, node);
+					appendStringInfoString(buf, " IS DISTINCT FROM ");
+					get_rule_expr_paren(arg2, context, true, node);
+					if (!PRETTY_PAREN(context))
+						appendStringInfoChar(buf, ')');
+				}
 			}
 			break;
 
 		case T_NullIfExpr:
 			{
 				NullIfExpr *nullifexpr = (NullIfExpr *) node;
+				Node	   *arg1 = (Node *) linitial(nullifexpr->args);
+				Node	   *arg2 = (Node *) lsecond(nullifexpr->args);
+				char	   *opname;
 
-				appendStringInfoString(buf, "NULLIF(");
-				get_rule_expr((Node *) nullifexpr->args, context, true);
-				appendStringInfoChar(buf, ')');
+				if (nullif_distinct_needs_qualified_form(nullifexpr->opno,
+														 arg1, arg2, &opname))
+				{
+					/*
+					 * The equality operator is not visible by its bare name
+					 * under the prevailing search_path, but NULLIF syntax
+					 * cannot carry a qualified operator name.  Emit instead
+					 * the equivalent expression "CASE WHEN a IS NOT NULL AND
+					 * b IS NOT NULL AND (a = b) THEN NULL ELSE a END" using
+					 * OPERATOR(schema.=).  The IS NOT NULL guards keep the
+					 * result faithful to NULLIF even for a non-strict "="
+					 * operator (NULLIF does not apply "=" when either input
+					 * is null).  CASE ... END is self-delimiting, so no outer
+					 * parentheses are needed.
+					 */
+					appendStringInfoString(buf, "CASE WHEN ");
+					get_rule_expr_paren(arg1, context, true, node);
+					appendStringInfoString(buf, " IS NOT NULL AND ");
+					get_rule_expr_paren(arg2, context, true, node);
+					appendStringInfoString(buf, " IS NOT NULL AND (");
+					get_rule_expr_paren(arg1, context, true, node);
+					appendStringInfo(buf, " %s ", opname);
+					get_rule_expr_paren(arg2, context, true, node);
+					appendStringInfoString(buf, ") THEN NULL ELSE ");
+					get_rule_expr_paren(arg1, context, true, node);
+					appendStringInfoString(buf, " END");
+				}
+				else
+				{
+					appendStringInfoString(buf, "NULLIF(");
+					get_rule_expr((Node *) nullifexpr->args, context, true);
+					appendStringInfoChar(buf, ')');
+				}
 			}
 			break;
 
-- 
2.54.0

