Hi hackers,
ruleutils.c treats x IS DOCUMENT as a self-delimiting,
function-like expression, like xmlelement(...). It is really a
postfix test like IS NULL, so printed as the operand of another
expression it loses the parentheses it needs. The text then reads
back as another expression, or is rejected. This happens outside
pretty mode too, so pg_dump output cannot be restored when such an
object exists. The two attached patches add tests and fix it.
The problem
-----------
In a view or rule, with IS DOCUMENT as the right operand of an
operator:
create table t (f bool, x xml);
create view v as select f = (x is document) as r1 from t;
non-pretty, before: SELECT (f = x IS DOCUMENT) AS r1
pretty, before: SELECT f = (x) IS DOCUMENT AS r1
both read back as (f = x) IS DOCUMENT:
ERROR: operator does not exist: boolean = xml
after: SELECT (f = (x IS DOCUMENT)) AS r1
SELECT f = (x IS DOCUMENT) AS r1
Index expressions, partition keys and extended statistics
expressions can be written without parentheses only if they are
function-like, and looks_like_function() says IS DOCUMENT is:
create index i on t ((x is document));
before: CREATE INDEX i ON public.t USING btree (x IS DOCUMENT)
ERROR: syntax error at or near "IS"
after: ... USING btree (((x IS DOCUMENT)))
partition by list ((x is document))
before: LIST (x IS DOCUMENT) -- same syntax error
after: LIST (((x IS DOCUMENT)))
create statistics s on (x is document), f from t;
before: ... ON f, (x) IS DOCUMENT FROM t -- syntax error
after: ... ON f, (x IS DOCUMENT) FROM t
CHECK constraints, column defaults and generated columns, trigger
WHEN conditions, policies, SQL-standard function bodies and
publication row filters go through the same code, and fail to
restore in the same way.
The fix
-------
0002 treats IS DOCUMENT like the other postfix tests.
- isSimpleNode(): takes IS DOCUMENT, and only it among the
XmlExprs, out of the function-like list, so that it is
judged like IS NULL.
- looks_like_function(): returns false for IS DOCUMENT. The other
XmlExprs (XMLCONCAT, XMLELEMENT, ...) stay function-like.
- get_rule_expr(): prints parentheses of its own outside pretty
mode, as IS NULL does. It also passes the single argument to
get_rule_expr_paren(), not the argument list, which is what gave
the needless parentheses of (x) IS DOCUMENT in pretty mode.
Outside pretty mode, IS DOCUMENT as the argument of a cast now
gets a second pair of parentheses, ((x IS DOCUMENT))::text. The
other postfix tests and operator expressions are already printed
that way, as in ((a IS NULL))::text, so this only makes the output
consistent, and it reads back the same.
Testing
-------
0001 adds the tests with the output before the fix, and 0002
updates it, so that the diff shows what changes. The view is
created again from its non-pretty text, and the statistics object
and the index from their printed definitions.
Some output still has more parentheses than it needs, such as
(((x IS DOCUMENT))) in a non-pretty index definition. That is not
specific to IS DOCUMENT: other postfix tests are printed the same
way there. I will approach it in a separate patch.
Back branches
-------------
I think this should be back-patched to 15. I applied both patches
to REL_15_STABLE through REL_19_STABLE.
The patches apply cleanly to REL_19 and pass make check. The
older branches need small adjustments: /* FALLTHROUGH */ in
place of pg_fallthrough (15-18), some hunks moved by hand
(15, 16) and, on 15, regenerated expected test output. With
those, all branches pass.
This came out of an AI-assisted audit of ruleutils.c. I will
post the other problems it found as separate, independent reports
as I work through them.
Best regards,
Henson
From 36b9793c05033c3c2c26cda4174160b3233b5ff5 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Fri, 2 Oct 2026 22:33:17 +0900
Subject: [PATCH v1 1/2] Add tests for IS DOCUMENT as an operand of other
expressions
IS DOCUMENT is a postfix test, like IS NULL, but it is printed as if
it were function-like, so it loses the parentheses it needs as an
operand.
The expected output shows the behavior before the fix. The next
commit fixes it and updates the output.
---
src/test/regress/expected/create_view.out | 62 ++++++++++++++++++++++-
src/test/regress/sql/create_view.sql | 26 ++++++++++
2 files changed, 87 insertions(+), 1 deletion(-)
diff --git a/src/test/regress/expected/create_view.out b/src/test/regress/expected/create_view.out
index 053fa56573f..6c1056748b3 100644
--- a/src/test/regress/expected/create_view.out
+++ b/src/test/regress/expected/create_view.out
@@ -2285,6 +2285,64 @@ select viewname from pg_views where viewname = 'tt27v'; -- Ok to access a system
(1 row)
reset restrict_nonsystem_relation_kind;
+-- IS DOCUMENT is a postfix test, so it needs parentheses as an operand
+create table tt29v_tbl (f bool, x xml);
+create view tt29v as
+ select f = (x is document) as r1,
+ (f >= (x is document)) and (f <= true) as r2
+ from tt29v_tbl;
+select pg_get_viewdef('tt29v'::regclass, false);
+ pg_get_viewdef
+--------------------------------------------------
+ SELECT (f = x IS DOCUMENT) AS r1, +
+ ((f >= x IS DOCUMENT) AND (f <= true)) AS r2+
+ FROM tt29v_tbl;
+(1 row)
+
+select pg_get_viewdef('tt29v'::regclass, true);
+ pg_get_viewdef
+----------------------------------------------
+ SELECT f = (x) IS DOCUMENT AS r1, +
+ f >= (x) IS DOCUMENT AND f <= true AS r2+
+ FROM tt29v_tbl;
+(1 row)
+
+select pg_get_viewdef('tt29v'::regclass, false) as def \gset
+create view tt29v_re as :def
+ERROR: operator does not exist: boolean = xml
+LINE 1: create view tt29v_re as SELECT (f = x IS DOCUMENT) AS r1,
+ ^
+DETAIL: No operator of that name accepts the given argument types.
+HINT: You might need to add explicit type casts.
+-- IS DOCUMENT also needs parentheses as an index or statistics expression,
+-- where only a function-like expression can be written without them
+create statistics tt29v_st on (x is document), f from tt29v_tbl;
+select pg_get_statisticsobjdef(oid) as def from pg_statistic_ext
+ where stxname = 'tt29v_st' \gset
+drop statistics tt29v_st;
+:def ;
+ERROR: syntax error at or near "IS"
+LINE 1: ...REATE STATISTICS testviewschm2.tt29v_st ON f, (x) IS DOCUMEN...
+ ^
+select pg_get_statisticsobjdef(oid) from pg_statistic_ext
+ where stxname = 'tt29v_st';
+ pg_get_statisticsobjdef
+-------------------------
+(0 rows)
+
+create index tt29v_idx on tt29v_tbl using btree ((x is document));
+select pg_get_indexdef('tt29v_idx'::regclass, 0, true);
+ pg_get_indexdef
+-------------------------------------------------------------------
+ CREATE INDEX tt29v_idx ON tt29v_tbl USING btree ((x) IS DOCUMENT)
+(1 row)
+
+select pg_get_indexdef('tt29v_idx'::regclass, 0, true) as def \gset
+drop index tt29v_idx;
+:def ;
+ERROR: syntax error at or near "IS"
+LINE 1: ...ATE INDEX tt29v_idx ON tt29v_tbl USING btree ((x) IS DOCUMEN...
+ ^
-- clean up all the random objects we made above
DROP SCHEMA temp_view_test CASCADE;
NOTICE: drop cascades to 27 other objects
@@ -2316,7 +2374,7 @@ drop cascades to view aliased_view_2
drop cascades to view aliased_view_3
drop cascades to view aliased_view_4
DROP SCHEMA testviewschm2 CASCADE;
-NOTICE: drop cascades to 80 other objects
+NOTICE: drop cascades to 82 other objects
DETAIL: drop cascades to table t1
drop cascades to view temporal1
drop cascades to view temporal2
@@ -2397,3 +2455,5 @@ drop cascades to view tt25v
drop cascades to view tt26v
drop cascades to table tt27v_tbl
drop cascades to view tt27v
+drop cascades to table tt29v_tbl
+drop cascades to view tt29v
diff --git a/src/test/regress/sql/create_view.sql b/src/test/regress/sql/create_view.sql
index 75b8cd5d0fb..3b748354102 100644
--- a/src/test/regress/sql/create_view.sql
+++ b/src/test/regress/sql/create_view.sql
@@ -838,6 +838,32 @@ insert into tt27v values (1); -- Error
select viewname from pg_views where viewname = 'tt27v'; -- Ok to access a system view.
reset restrict_nonsystem_relation_kind;
+-- IS DOCUMENT is a postfix test, so it needs parentheses as an operand
+create table tt29v_tbl (f bool, x xml);
+create view tt29v as
+ select f = (x is document) as r1,
+ (f >= (x is document)) and (f <= true) as r2
+ from tt29v_tbl;
+select pg_get_viewdef('tt29v'::regclass, false);
+select pg_get_viewdef('tt29v'::regclass, true);
+select pg_get_viewdef('tt29v'::regclass, false) as def \gset
+create view tt29v_re as :def
+
+-- IS DOCUMENT also needs parentheses as an index or statistics expression,
+-- where only a function-like expression can be written without them
+create statistics tt29v_st on (x is document), f from tt29v_tbl;
+select pg_get_statisticsobjdef(oid) as def from pg_statistic_ext
+ where stxname = 'tt29v_st' \gset
+drop statistics tt29v_st;
+:def ;
+select pg_get_statisticsobjdef(oid) from pg_statistic_ext
+ where stxname = 'tt29v_st';
+create index tt29v_idx on tt29v_tbl using btree ((x is document));
+select pg_get_indexdef('tt29v_idx'::regclass, 0, true);
+select pg_get_indexdef('tt29v_idx'::regclass, 0, true) as def \gset
+drop index tt29v_idx;
+:def ;
+
-- clean up all the random objects we made above
DROP SCHEMA temp_view_test CASCADE;
DROP SCHEMA testviewschm2 CASCADE;
--
2.54.0 (Apple Git-157)
From 94ad677d65de33dd3657e18e94a28b61b44df65b Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Fri, 2 Oct 2026 22:34:04 +0900
Subject: [PATCH v1 2/2] Parenthesize IS DOCUMENT where it is an operand
isSimpleNode() and looks_like_function() treated every XmlExpr as
self-delimiting. "x IS DOCUMENT" is not: it is a postfix test, like
IS NULL. Printed as the operand of another operator, or as the
operand of a postfix test, it was therefore not parenthesized and
could not be parsed back, or was parsed as something else. Treat IS
DOCUMENT as neither simple nor function-like, and give it the same
parentheses as the other postfix tests.
Also pass the single argument of IS DOCUMENT, not the argument list,
to get_rule_expr_paren().
Backpatch-through: 15
---
src/backend/utils/adt/ruleutils.c | 33 +++++++++++++++++----
src/test/regress/expected/create_view.out | 35 +++++++++--------------
2 files changed, 41 insertions(+), 27 deletions(-)
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index 5e8e1683db0..e94437b839e 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -8903,7 +8903,6 @@ isSimpleNode(Node *node, Node *parentNode, int prettyFlags)
case T_CoalesceExpr:
case T_MinMaxExpr:
case T_SQLValueFunction:
- case T_XmlExpr:
case T_NextValueExpr:
case T_NullIfExpr:
case T_Aggref:
@@ -9005,6 +9004,17 @@ isSimpleNode(Node *node, Node *parentNode, int prettyFlags)
}
pg_fallthrough;
+ case T_XmlExpr:
+
+ /*
+ * Function-like (name(..)), except "x IS DOCUMENT", which is a
+ * postfix test like IS NULL. (The IsA check is needed because
+ * T_OpExpr falls through to here.)
+ */
+ if (IsA(node, XmlExpr) && ((XmlExpr *) node)->op != IS_DOCUMENT)
+ return true;
+ pg_fallthrough;
+
case T_SubLink:
case T_NullTest:
case T_BooleanTest:
@@ -9029,13 +9039,14 @@ isSimpleNode(Node *node, Node *parentNode, int prettyFlags)
case T_RowExpr: /* other separators */
case T_CoalesceExpr: /* own parentheses */
case T_MinMaxExpr: /* own parentheses */
- case T_XmlExpr: /* own parentheses */
case T_NullIfExpr: /* other separators */
case T_Aggref: /* own parentheses */
case T_GroupingFunc: /* own parentheses */
case T_WindowFunc: /* own parentheses */
case T_CaseExpr: /* other separators */
return true;
+ case T_XmlExpr: /* own parentheses, except IS DOCUMENT */
+ return ((XmlExpr *) parentNode)->op != IS_DOCUMENT;
default:
return false;
}
@@ -9081,7 +9092,6 @@ isSimpleNode(Node *node, Node *parentNode, int prettyFlags)
case T_RowExpr: /* other separators */
case T_CoalesceExpr: /* own parentheses */
case T_MinMaxExpr: /* own parentheses */
- case T_XmlExpr: /* own parentheses */
case T_NullIfExpr: /* other separators */
case T_Aggref: /* own parentheses */
case T_GroupingFunc: /* own parentheses */
@@ -9089,6 +9099,8 @@ isSimpleNode(Node *node, Node *parentNode, int prettyFlags)
case T_CaseExpr: /* other separators */
case T_JsonExpr: /* own parentheses */
return true;
+ case T_XmlExpr: /* own parentheses, except IS DOCUMENT */
+ return ((XmlExpr *) parentNode)->op != IS_DOCUMENT;
default:
return false;
}
@@ -10141,6 +10153,9 @@ get_rule_expr(Node *node, deparse_context *context,
appendStringInfoString(buf, "XMLSERIALIZE(");
break;
case IS_DOCUMENT:
+ /* postfix test, like IS NULL: not self-delimiting */
+ if (!PRETTY_PAREN(context))
+ appendStringInfoChar(buf, '(');
break;
}
if (xexpr->op == IS_XMLPARSE || xexpr->op == IS_XMLSERIALIZE)
@@ -10248,7 +10263,9 @@ get_rule_expr(Node *node, deparse_context *context,
}
break;
case IS_DOCUMENT:
- get_rule_expr_paren((Node *) xexpr->args, context, false, node);
+ Assert(list_length(xexpr->args) == 1);
+ get_rule_expr_paren((Node *) linitial(xexpr->args),
+ context, false, node);
break;
}
}
@@ -10264,7 +10281,11 @@ get_rule_expr(Node *node, deparse_context *context,
}
if (xexpr->op == IS_DOCUMENT)
+ {
appendStringInfoString(buf, " IS DOCUMENT");
+ if (!PRETTY_PAREN(context))
+ appendStringInfoChar(buf, ')');
+ }
else
appendStringInfoChar(buf, ')');
}
@@ -10766,10 +10787,12 @@ looks_like_function(Node *node)
case T_CoalesceExpr:
case T_MinMaxExpr:
case T_SQLValueFunction:
- case T_XmlExpr:
case T_JsonExpr:
/* these are all accepted by func_expr_common_subexpr */
return true;
+ case T_XmlExpr:
+ /* all but IS DOCUMENT, which is a postfix test */
+ return ((XmlExpr *) node)->op != IS_DOCUMENT;
default:
break;
}
diff --git a/src/test/regress/expected/create_view.out b/src/test/regress/expected/create_view.out
index 6c1056748b3..d19510b7892 100644
--- a/src/test/regress/expected/create_view.out
+++ b/src/test/regress/expected/create_view.out
@@ -2292,28 +2292,23 @@ create view tt29v as
(f >= (x is document)) and (f <= true) as r2
from tt29v_tbl;
select pg_get_viewdef('tt29v'::regclass, false);
- pg_get_viewdef
---------------------------------------------------
- SELECT (f = x IS DOCUMENT) AS r1, +
- ((f >= x IS DOCUMENT) AND (f <= true)) AS r2+
+ pg_get_viewdef
+----------------------------------------------------
+ SELECT (f = (x IS DOCUMENT)) AS r1, +
+ ((f >= (x IS DOCUMENT)) AND (f <= true)) AS r2+
FROM tt29v_tbl;
(1 row)
select pg_get_viewdef('tt29v'::regclass, true);
pg_get_viewdef
----------------------------------------------
- SELECT f = (x) IS DOCUMENT AS r1, +
- f >= (x) IS DOCUMENT AND f <= true AS r2+
+ SELECT f = (x IS DOCUMENT) AS r1, +
+ f >= (x IS DOCUMENT) AND f <= true AS r2+
FROM tt29v_tbl;
(1 row)
select pg_get_viewdef('tt29v'::regclass, false) as def \gset
create view tt29v_re as :def
-ERROR: operator does not exist: boolean = xml
-LINE 1: create view tt29v_re as SELECT (f = x IS DOCUMENT) AS r1,
- ^
-DETAIL: No operator of that name accepts the given argument types.
-HINT: You might need to add explicit type casts.
-- IS DOCUMENT also needs parentheses as an index or statistics expression,
-- where only a function-like expression can be written without them
create statistics tt29v_st on (x is document), f from tt29v_tbl;
@@ -2321,28 +2316,23 @@ select pg_get_statisticsobjdef(oid) as def from pg_statistic_ext
where stxname = 'tt29v_st' \gset
drop statistics tt29v_st;
:def ;
-ERROR: syntax error at or near "IS"
-LINE 1: ...REATE STATISTICS testviewschm2.tt29v_st ON f, (x) IS DOCUMEN...
- ^
select pg_get_statisticsobjdef(oid) from pg_statistic_ext
where stxname = 'tt29v_st';
- pg_get_statisticsobjdef
--------------------------
-(0 rows)
+ pg_get_statisticsobjdef
+-------------------------------------------------------------------------------
+ CREATE STATISTICS testviewschm2.tt29v_st ON f, (x IS DOCUMENT) FROM tt29v_tbl
+(1 row)
create index tt29v_idx on tt29v_tbl using btree ((x is document));
select pg_get_indexdef('tt29v_idx'::regclass, 0, true);
pg_get_indexdef
-------------------------------------------------------------------
- CREATE INDEX tt29v_idx ON tt29v_tbl USING btree ((x) IS DOCUMENT)
+ CREATE INDEX tt29v_idx ON tt29v_tbl USING btree ((x IS DOCUMENT))
(1 row)
select pg_get_indexdef('tt29v_idx'::regclass, 0, true) as def \gset
drop index tt29v_idx;
:def ;
-ERROR: syntax error at or near "IS"
-LINE 1: ...ATE INDEX tt29v_idx ON tt29v_tbl USING btree ((x) IS DOCUMEN...
- ^
-- clean up all the random objects we made above
DROP SCHEMA temp_view_test CASCADE;
NOTICE: drop cascades to 27 other objects
@@ -2374,7 +2364,7 @@ drop cascades to view aliased_view_2
drop cascades to view aliased_view_3
drop cascades to view aliased_view_4
DROP SCHEMA testviewschm2 CASCADE;
-NOTICE: drop cascades to 82 other objects
+NOTICE: drop cascades to 83 other objects
DETAIL: drop cascades to table t1
drop cascades to view temporal1
drop cascades to view temporal2
@@ -2457,3 +2447,4 @@ drop cascades to table tt27v_tbl
drop cascades to view tt27v
drop cascades to table tt29v_tbl
drop cascades to view tt29v
+drop cascades to view tt29v_re
--
2.54.0 (Apple Git-157)