BUG #19750: tsquery output omits parentheses, so the text reparses to a different value

From: PG Bug reporting form <noreply(at)postgresql(dot)org>
To: pgsql-bugs(at)lists(dot)postgresql(dot)org
Cc: kehan5800(at)gmail(dot)com
Subject: BUG #19750: tsquery output omits parentheses, so the text reparses to a different value
Date: 2026-10-04 05:33:50
Message-ID: 19750-d4e6f6e6e93000ba@postgresql.org
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-bugs

The following bug has been logged on the website:

Bug reference: 19750
Logged by: Ke
Email address: kehan5800(at)gmail(dot)com
PostgreSQL version: 18.6
Operating system: Ubuntu 22.04.2 x86_64
Description:

tsquery's output function, infix() in src/backend/utils/adt/tsquery.c,
parenthesises a binary operator's operand only when the operand's operator
has lower priority than the parent (or, for <->, when it is the right
operand). When the same operator, & or |, is nested on the right, no
parentheses are written. The parser is left-associative, so that text
reads back as a different tree, and tsquery equality compares trees:

=# select q, q::tsquery::text as printed,
(q::tsquery::text::tsquery = q::tsquery)::int as round_trips
from (values ('a & b & c'), ('a & (b & c)'), ('(a & b) & c'),
('a | (b | c)'), ('a & (b | c)'), ('a <-> (b <-> c)'))
v(q);
q | printed | round_trips
-----------------+-------------------------+-------------
a & b & c | 'a' & 'b' & 'c' | 1
a & (b & c) | 'a' & 'b' & 'c' | 0
(a & b) & c | 'a' & 'b' & 'c' | 1
a | (b | c) | 'a' | 'b' | 'c' | 0
a & (b | c) | 'a' & ( 'b' | 'c' ) | 1
a <-> (b <-> c) | 'a' <-> ( 'b' <-> 'c' ) | 1
(6 rows)

Two different values print as 'a' & 'b' & 'c':

=# select count(distinct q)
from (values ('a & (b & c)'::tsquery), ('a & b & c'::tsquery))
v(q);
count
-------
2

=# select count(distinct q::text)
from (values ('a & (b & c)'::tsquery), ('a & b & c'::tsquery))
v(q);
count
-------
1

You get this shape without writing parentheses. The && and || operators,
which applications use to build a query from parts, produce it:

=# select ('a'::tsquery && ('b & c')::tsquery)::text as printed,
(('a'::tsquery && ('b & c')::tsquery)::text::tsquery
= ('a'::tsquery && ('b & c')::tsquery))::int as round_trips;
printed | round_trips
-----------------+-------------
'a' & 'b' & 'c' | 0

A text dump/restore, which goes through tsqueryout/tsqueryin, therefore
changes the stored value:

=# create table tq (q tsquery);
=# insert into tq values ('a'::tsquery && ('b & c')::tsquery);
=# select (select q from tq) = (select q::text::tsquery from tq);
?column?
----------
f

Expected: either the output is 'a' & ( 'b' & 'c' ), which reads back to the
same value (as is already done for <->), or the two values compare equal.
round_trips should be 1 in every row above.

The two trees match the same documents, since & and | are associative, so
@@ results do not change. What changes is the value itself. Equality,
ordering, GROUP BY, DISTINCT and a unique index on a tsquery column all
treat
the restored value differently from the original.

Suggested fix: in infix(), also parenthesise the right operand of & and |
when it has the same priority as the parent. That is already done for
OP_PHRASE through the rightPhraseOp argument ("phrase operator depends on
order"). Generalising that, so a right operand is parenthesised whenever
its priority is <= the parent's (not only for OP_PHRASE), would do it. The
cost is extra parentheses in the output for right-nested & and | only;
left-nested and flat chains print the same as today.

Reproduced on master 20devel @ 45277ca0d1cb, 18.6 and 17.11.

Responses

Browse pgsql-bugs by date

  From Date Subject
Next Message David Rowley 2026-10-04 05:37:29 Re: BUG #19742: `INTERSECT` under a `UNION ALL` with an empty arm fails with "could not find pathkey item t"
Previous Message shihao zhong 2026-10-04 05:23:29 Re: BUG #19742: `INTERSECT` under a `UNION ALL` with an empty arm fails with "could not find pathkey item t"