Hello everyone,

The attached patch changes how GRAPH_TABLE rewrites undirected edge patterns (-[e]-) to UNION ALL subquery instead of emitting an OR on both directions quals.

Background
----------

When an undirected edge pattern matches an edge table whose source and
destination vertex table are the same, the current implementation combines the equi-join
conditions for both traversal directions into a single OR qual:

    WHERE (e.src = v1.id AND e.dst = v2.id)
       OR (e.src = v2.id AND e.dst = v1.id)

This generally implies using BitmapOr or a full OR evaluation which is very unefficient in queries with large intermediate results (see benchmarks below).

What can we do
--------------

For the case where src and dest vertex patterns are different path factors, but same path element,
the edge relation RTE is replaced with a UNION ALL subquery:

(SELECT src, dst, ...other cols... FROM edge -- forward branch
     UNION ALL
SELECT dst, src, ...other cols... FROM edge -- backward branch WHERE NOT (src = dst)) -- this excludes self-loops


The subqueries are eventually pulled up by the planner as its a simple union containing simple subquries too, everything should be pulled up with pull_up_simple_union_all and pull_up_simple_subquery (if i'm not mistaked)

Special cases
-------------

1. Same path factor (self-referencing pattern, e.g. (p)-[k]-(p)):
   pf->src_pf == pf->dest_pf.  In this case either orientation already
   satisfies the join, so the UNION ALL is skipped entirely.

2. Self-loop deduplication:
   With a UNION ALL, a self-loop edge (src = dst) would appear once in
   the forward branch and once in the backward branch, giving two result
rows for a single physical edge. From the regress tests and 19beta1 behaviour, i understand that the SQL/PGQ standard enforces that an edge orientation is unqiue, so a (src=1, dest=1) appears once.

The backward branch therefore carries a NOT(src = dst) filter (built by build_edge_self_loop_filter()) to prevent duplication, note that src and dest can be composite.


Permissions
-----------
Although i'm not much familiar with permissions, i followed the same idea done in all of rewriteGraphTable, subqueries have their own perminfos which will be individually evalued later on. Also for both directions subqueries i set the same selectedCols for the columns of the edge, which are all non-dropped columns of the edge table.

I hope i didn't misunderstand how it should be in column permissions.


Benchamrks
----------
Using LSQB from LDBC [1], [2] picking only Q6 and Q9 which are the hardest queries (very large intermediate results) in the benchmark (doing undirected edge patterns, which is our target for improvement). Everything is ran on an Intel 1255U, 16GB RAM (everything warm), using Docker, running vscode and firefox at the same time.

From [2] you can see query shapes, dataset sizes, degree distribution, etc.

Here are both queries so you don't have to...

Q6:
SELECT count(*)
FROM GRAPH_TABLE (lsqb
MATCH (person1 IS Person)-[k IS knows]-(person2 IS Person)-[k2 IS knows]-(person3 IS Person)-[hi IS hasInterest]->(T IS Tag)
    WHERE person1.id <> person3.id
    COLUMNS (1 AS dummy)
);

Q9:
SELECT count(*)
FROM GRAPH_TABLE (lsqb
MATCH (person1 IS Person)-[k IS knows]-(person2 IS Person)-[k2 IS knows]-(person3 IS Person)-[hi IS hasInterest]->(T IS Tag)
  WHERE person1.id <> person3.id
  COLUMNS (person1.id as p1_id, person3.id as p3_id)
) g
LEFT JOIN (select person1id, person2id FROM Person_knows_Person UNION ALL select person2id, person1id from Person_knows_person) pkp3
       ON pkp3.Person1Id = g.p1_id
      AND pkp3.Person2Id = g.p3_id
    WHERE pkp3.Person1Id IS NULL;

Format:
Postgres-Version ScaleFactor Query-Number Latency(s) query result (count(*))

SF 0.1:
PostgreSQL-PGQ (v19beta1) 0.1 6 91.6983 55607896
PostgreSQL-PGQ (v19beta1) 0.1 9 99.2815 51009398
PostgreSQL-Patched 0.1 6 1.6103 55607896
PostgreSQL-Patched 0.1 9 2.1719 51009398

SF 0.3: (timeout set to 300 seconds)
PostgreSQL-PGQ (v19beta1) 0.3 6 300.0021 TIMEOUT
PostgreSQL-PGQ (v19beta1) 0.3 9 300.3658 TIMEOUT
PostgreSQL-Patched 0.3 6 9.1556 285509755
PostgreSQL-Patched 0.3 9 11.0674 268837983

SF 1: (timeout set to 40 minutes)
PostgreSQL-PGQ (v19beta1) 1 6 2400.1786 TIMEOUT
PostgreSQL-PGQ (v19beta1) 1 9 2400.1071 TIMEOUT
PostgreSQL-Patched 1 6 41.7566 1668134320
PostgreSQL-Patched 1 9 98.0370 1596153418

---------------

I'll be showing query plans for SF 0.1 Q6 as an example:

PostgreSQL-PGQ (v19beta1)

QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=119023133.69..119023133.70 rows=1 width=8) (actual time=87598.444..87598.445 rows=1.00 loops=1)
   Buffers: shared hit=260492732
-> Nested Loop (cost=1.97..118978584.96 rows=17819493 width=0) (actual time=2.890..86862.439 rows=55607896.00 loops=1)
         Buffers: shared hit=260492732
-> Nested Loop (cost=1.68..118531927.95 rows=17819493 width=8) (actual time=2.866..80452.271 rows=55607896.00 loops=1)
               Buffers: shared hit=260484267
-> Nested Loop (cost=1.38..118288857.60 rows=773376 width=8) (actual time=2.852..78406.879 rows=2393846.00 loops=1)
                     Buffers: shared hit=260481060
-> Nested Loop (cost=0.69..6217068.50 rows=61622719 width=24) (actual time=0.955..6768.780 rows=61622730.00 loops=1)
                           Join Filter: (person.id <> person_2.id)
                           Rows Removed by Join Filter: 36270
                           Buffers: shared hit=11596294
-> Nested Loop (cost=0.69..5292154.25 rows=36270 width=16) (actual time=0.945..3463.242 rows=36270.00 loops=1)
                                 Buffers: shared hit=11596286
-> Nested Loop (cost=0.00..36179.25 rows=2890000 width=16) (actual time=0.031..140.342 rows=2890000.00 loops=1)
                                       Buffers: shared hit=16
-> Seq Scan on person person_1 (cost=0.00..25.00 rows=1700 width=8) (actual time=0.017..0.332 rows=1700.00 loops=1)
                                             Buffers: shared hit=8
-> Materialize (cost=0.00..33.50 rows=1700 width=8) (actual time=0.000..0.026 rows=1700.00 loops=1700) Storage: Memory Maximum Storage: 70kB
                                             Buffers: shared hit=8
-> Seq Scan on person person_2 (cost=0.00..25.00 rows=1700 width=8) (actual time=0.007..0.099 rows=1700.00 loops=1)
                                                   Buffers: shared hit=8
-> Bitmap Heap Scan on person_knows_person person_knows_person_1 (cost=0.69..1.81 rows=1 width=16) (actual time=0.001..0.001 rows=0.01 loops=2890000) Recheck Cond: (((person_1.id = person1id) AND (person_2.id = person2id)) OR ((person_2.id = person1id) AND (person_1.id = person2id)))
                                       Heap Blocks: exact=36270
                                       Buffers: shared hit=11596270
-> BitmapOr (cost=0.69..0.69 rows=1 width=0) (actual time=0.001..0.001 rows=0.00 loops=2890000) Buffers: shared hit=11560000 -> Bitmap Index Scan on person_knows_person_pkey (cost=0.00..0.34 rows=1 width=0) (actual time=0.000..0.000 rows=0.01 loops=2890000) Index Cond: ((person1id = person_1.id) AND (person2id = person_2.id)) Index Searches: 2890000 Buffers: shared hit=5780000 -> Bitmap Index Scan on person_knows_person_pkey (cost=0.00..0.34 rows=1 width=0) (actual time=0.000..0.000 rows=0.01 loops=2890000) Index Cond: ((person1id = person_2.id) AND (person2id = person_1.id)) Index Searches: 2890000 Buffers: shared hit=5780000 -> Materialize (cost=0.00..33.50 rows=1700 width=8) (actual time=0.000..0.025 rows=1700.00 loops=36270)
                                 Storage: Memory  Maximum Storage: 70kB
                                 Buffers: shared hit=8
-> Seq Scan on person (cost=0.00..25.00 rows=1700 width=8) (actual time=0.006..0.197 rows=1700.00 loops=1)
                                       Buffers: shared hit=8
-> Bitmap Heap Scan on person_knows_person (cost=0.69..1.81 rows=1 width=16) (actual time=0.001..0.001 rows=0.04 loops=61622730) Recheck Cond: (((person.id = person1id) AND (person_1.id = person2id)) OR ((person_1.id = person1id) AND (person.id = person2id)))
                           Heap Blocks: exact=2393846
                           Buffers: shared hit=248884766
-> BitmapOr (cost=0.69..0.69 rows=1 width=0) (actual time=0.001..0.001 rows=0.00 loops=61622730)
                                 Buffers: shared hit=246490920
-> Bitmap Index Scan on person_knows_person_pkey (cost=0.00..0.34 rows=1 width=0) (actual time=0.000..0.000 rows=0.02 loops=61622730) Index Cond: ((person1id = person.id) AND (person2id = person_1.id))
                                       Index Searches: 61622730
                                       Buffers: shared hit=123245460
-> Bitmap Index Scan on person_knows_person_pkey (cost=0.00..0.34 rows=1 width=0) (actual time=0.000..0.000 rows=0.02 loops=61622730) Index Cond: ((person1id = person_1.id) AND (person2id = person.id))
                                       Index Searches: 61622730
                                       Buffers: shared hit=123245460
-> Memoize (cost=0.30..0.83 rows=24 width=16) (actual time=0.000..0.000 rows=23.23 loops=2393846)
                     Cache Key: person_2.id
                     Cache Mode: logical
Estimates: capacity=1700 distinct keys=1700 lookups=773376 hit percent=99.78% Hits: 2392308 Misses: 1538 Evictions: 0 Overflows: 0 Memory Usage: 1787kB
                     Buffers: shared hit=3207
-> Index Only Scan using person_hasinterest_tag_pkey on person_hasinterest_tag (cost=0.29..0.82 rows=24 width=16) (actual time=0.002..0.003 rows=23.28 loops=1538)
                           Index Cond: (personid = person_2.id)
                           Heap Fetches: 0
                           Index Searches: 1538
                           Buffers: shared hit=3207
-> Memoize (cost=0.30..0.31 rows=1 width=8) (actual time=0.000..0.000 rows=1.00 loops=55607896)
               Cache Key: person_hasinterest_tag.tagid
               Cache Mode: logical
Estimates: capacity=3904 distinct keys=3904 lookups=17819493 hit percent=99.98% Hits: 55603664 Misses: 4232 Evictions: 0 Overflows: 0 Memory Usage: 463kB
               Buffers: shared hit=8465
-> Index Only Scan using tag_pkey on tag (cost=0.29..0.30 rows=1 width=8) (actual time=0.001..0.001 rows=1.00 loops=4232)
                     Index Cond: (id = person_hasinterest_tag.tagid)
                     Heap Fetches: 0
                     Index Searches: 4232
                     Buffers: shared hit=8465
 Planning:
   Buffers: shared hit=24
 Planning Time: 2.211 ms

Time: 87608.729 ms (01:27.609)

---------------

The goal of the patch is to try and avoid this:


-> Bitmap Heap Scan on person_knows_person (cost=0.69..1.81 rows=1 width=16) (actual time=0.001..0.001 rows=0.04 loops=61622730) Recheck Cond: (((person.id = person1id) AND (person_1.id = person2id)) OR ((person_1.id = person1id) AND (person.id = person2id)))
         Heap Blocks: exact=2393846
         Buffers: shared hit=248884766
-> BitmapOr (cost=0.69..0.69 rows=1 width=0) (actual time=0.001..0.001 rows=0.00 loops=61622730)
               Buffers: shared hit=246490920
-> Bitmap Index Scan on person_knows_person_pkey (cost=0.00..0.34 rows=1 width=0) (actual time=0.000..0.000 rows=0.02 loops=61622730) Index Cond: ((person1id = person.id) AND (person2id = person_1.id))
                     Index Searches: 61622730
                     Buffers: shared hit=123245460
-> Bitmap Index Scan on person_knows_person_pkey (cost=0.00..0.34 rows=1 width=0) (actual time=0.000..0.000 rows=0.02 loops=61622730) Index Cond: ((person1id = person_1.id) AND (person2id = person.id))
                     Index Searches: 61622730
                     Buffers: shared hit=123245460
:
---------------

The UNION ALL patch gives this:

------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
---------
Finalize Aggregate (cost=86444.69..86444.70 rows=1 width=8) (actual time=1594.615..1605.134 rows=1.00 loops=1)
   Buffers: shared hit=13124
-> Gather (cost=86444.47..86444.68 rows=2 width=8) (actual time=1582.094..1605.127 rows=2.00 loops=1)
         Workers Planned: 1
         Workers Launched: 1
         Buffers: shared hit=13124
-> Partial Aggregate (cost=85444.47..85444.48 rows=1 width=8) (actual time=1584.226..1585.179 rows=1.00 loops=2)
               Buffers: shared hit=13124
-> Parallel Hash Join (cost=7796.34..59370.67 rows=10429521 width=0) (actual time=230.138..1101.048 rows=27803948.00 loops=2) Hash Cond: (person_hasinterest_tag.personid = person_2.id)
                     Buffers: shared hit=13124
-> Nested Loop (cost=0.58..2617.87 rows=23041 width=8) (actual time=0.161..11.623 rows=19585.00 loops=2)
                           Buffers: shared hit=12424
-> Parallel Index Only Scan using person_hasinterest_tag_pkey on person_hasinterest_tag (cost=0.29..840.11 rows=23041 width=16) (actual time=0.051..1.468 rows=19585.00 loops=2
)
                                 Heap Fetches: 875
                                 Index Searches: 1
                                 Buffers: shared hit=187
-> Memoize (cost=0.30..0.32 rows=1 width=8) (actual time=0.000..0.000 rows=1.00 loops=39170)
                                 Cache Key: person_hasinterest_tag.tagid
                                 Cache Mode: logical
Estimates: capacity=3926 distinct keys=3926 lookups=23041 hit percent=82.96% Hits: 15232 Misses: 3058 Evictions: 0 Overflows: 0 Memory Usage: 335kB
                                 Buffers: shared hit=12237
Worker 0: Hits: 17823 Misses: 3057 Evictions: 0 Overflows: 0 Memory Usage: 335kB -> Index Only Scan using tag_pkey on tag (cost=0.29..0.31 rows=1 width=8) (actual time=0.001..0.001 rows=1.00 loops=6115) Index Cond: (id = person_hasinterest_tag.tagid)
                                       Heap Fetches: 107
                                       Index Searches: 6115
                                       Buffers: shared hit=12237
-> Parallel Hash (cost=3787.95..3787.95 rows=320625 width=16) (actual time=226.165..227.116 rows=1196923.00 loops=2) Buckets: 4194304 (originally 1048576) Batches: 1 (originally 1) Memory Usage: 169696kB
                           Buffers: shared hit=700
-> Parallel Hash Join (cost=1141.26..3787.95 rows=320625 width=16) (actual time=7.547..66.224 rows=1196923.00 loops=2) Hash Cond: (person_knows_person_2.person1id = person_1.id)
                                 Join Filter: (person.id <> person_2.id)
                                 Rows Removed by Join Filter: 18135
                                 Buffers: shared hit=700
-> Hash Join (cost=48.25..816.65 rows=15075 width=16) (actual time=0.176..3.078 rows=18135.00 loops=2) Hash Cond: (person_knows_person_2.person2id = person.id)
                                       Buffers: shared hit=340
-> Parallel Append (cost=0.00..728.73 rows=15074 width=16) (actual time=0.012..1.213 rows=18135.00 loops=2)
                                             Buffers: shared hit=320
-> Seq Scan on person_knows_person person_knows_person_2 (cost=0.00..386.69 rows=18044 width=16) (actual time=0.020..0.995 rows=18135.00 loops=1) Filter: (person1id <> person2id) Buffers: shared hit=160 -> Parallel Seq Scan on person_knows_person (cost=0.00..266.68 rows=10668 width=16) (actual time=0.002..0.551 rows=18135.00 loops=1) Buffers: shared hit=160 -> Hash (cost=27.00..27.00 rows=1700 width=8) (actual time=0.152..0.153 rows=1700.00 loops=2) Buckets: 2048 Batches: 1 Memory Usage: 83kB
                                             Buffers: shared hit=20
-> Seq Scan on person (cost=0.00..27.00 rows=1700 width=8) (actual time=0.011..0.053 rows=1700.00 loops=2) Buffers: shared hit=20 -> Parallel Hash (cost=904.58..904.58 rows=15075 width=32) (actual time=7.239..7.241 rows=18135.00 loops=2) Buckets: 65536 Batches: 1 Memory Usage: 2816kB
                                       Buffers: shared hit=360
-> Hash Join (cost=96.50..904.58 rows=15075 width=32) (actual time=0.340..4.534 rows=18135.00 loops=2) Hash Cond: (person_knows_person_3.person1id = person_2.id)
                                             Buffers: shared hit=360
-> Hash Join (cost=48.25..816.65 rows=15075 width=24) (actual time=0.158..2.826 rows=18135.00 loops=2) Hash Cond: (person_knows_person_3.person2id = person_1.id) Buffers: shared hit=340 -> Parallel Append (cost=0.00..728.73 rows=15074 width=16) (actual time=0.018..1.097 rows=18135.00 loops=2) Buffers: shared hit=320 -> Seq Scan on person_knows_person person_knows_person_3 (cost=0.00..386.69 rows=18044 width=16) (actual time=0.032..1.032 rows=18135.00 loops=1) Filter: (person1id <> person2id) Buffers: shared hit=160 -> Parallel Seq Scan on person_knows_person person_knows_person_1 (cost=0.00..266.68 rows=10668 width=16) (actual time=0.006..0.207 rows=9067.50
loops=2)
Buffers: shared hit=160 -> Hash (cost=27.00..27.00 rows=1700 width=8) (actual time=0.129..0.130 rows=1700.00 loops=2) Buckets: 2048 Batches: 1 Memory Usage: 83kB Buffers: shared hit=20 -> Seq Scan on person person_1 (cost=0.00..27.00 rows=1700 width=8) (actual time=0.011..0.042 rows=1700.00 loops=2) Buffers: shared hit=20 -> Hash (cost=27.00..27.00 rows=1700 width=8) (actual time=0.166..0.166 rows=1700.00 loops=2) Buckets: 2048 Batches: 1 Memory Usage: 83kB Buffers: shared hit=20 -> Seq Scan on person person_2 (cost=0.00..27.00 rows=1700 width=8) (actual time=0.028..0.077 rows=1700.00 loops=2) Buffers: shared hit=20
 Planning:
   Buffers: shared hit=32
 Planning Time: 9.442 ms
 Execution Time: 1605.346 ms
(82 rows)

Time: 1615.777 ms (00:01.616)

---------------


Here's also another plan from patched version on SF 1:

QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Finalize Aggregate (cost=1540136.99..1540137.00 rows=1 width=8) (actual time=39407.590..39411.145 rows=1.00 loops=1)
   Buffers: shared hit=7089
-> Gather (cost=1540136.77..1540136.98 rows=2 width=8) (actual time=39376.380..39411.138 rows=3.00 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         Buffers: shared hit=7089
-> Partial Aggregate (cost=1539136.77..1539136.78 rows=1 width=8) (actual time=39374.325..39374.331 rows=1.00 loops=3)
               Buffers: shared hit=7089
-> Parallel Hash Join (cost=16706.64..1090714.77 rows=179368801 width=0) (actual time=89.914..29316.892 rows=556044773.33 loops=3) Hash Cond: (person_knows_person_2.person2id = person_1.id)
                     Join Filter: (person.id <> person_2.id)
                     Rows Removed by Join Filter: 3493080
                     Buffers: shared hit=7089
-> Parallel Hash Join (cost=6342.79..42247.34 rows=4370837 width=16) (actual time=29.291..285.672 rows=3493080.00 loops=3) Hash Cond: (person_knows_person_2.person1id = person_2.id)
                           Buffers: shared hit=4299
-> Parallel Append (cost=0.00..6431.58 rows=188106 width=16) (actual time=0.009..15.821 rows=150862.00 loops=3)
                                 Buffers: shared hit=2496
-> Parallel Seq Scan on person_knows_person person_knows_person_2 (cost=0.00..2911.92 rows=132448 width=16) (actual time=0.006..8.338 rows=75431.00 loops=3)
                                       Filter: (person1id <> person2id)
                                       Buffers: shared hit=1248
-> Parallel Seq Scan on person_knows_person person_knows_person_1 (cost=0.00..2579.14 rows=133114 width=16) (actual time=0.008..3.772 rows=113146.50 loops=2)
                                       Buffers: shared hit=1248
-> Parallel Hash (cost=4463.40..4463.40 rows=150351 width=16) (actual time=29.011..29.013 rows=85198.67 loops=3) Buckets: 262144 Batches: 1 Memory Usage: 14080kB
                                 Buffers: shared hit=1803
-> Hash Join (cost=730.30..4463.40 rows=150351 width=16) (actual time=1.985..16.399 rows=85198.67 loops=3) Hash Cond: (person_hasinterest_tag.tagid = tag.id)
                                       Buffers: shared hit=1803
-> Hash Join (cost=296.50..3634.83 rows=150351 width=24) (actual time=0.740..8.790 rows=85198.67 loops=3) Hash Cond: (person_hasinterest_tag.personid = person_2.id)
                                             Buffers: shared hit=1587
-> Parallel Seq Scan on person_hasinterest_tag (cost=0.00..2943.51 rows=150351 width=16) (actual time=0.009..1.821 rows=85198.67 loops=3) Buffers: shared hit=1440 -> Hash (cost=159.00..159.00 rows=11000 width=8) (actual time=0.681..0.681 rows=11000.00 loops=3) Buckets: 16384 Batches: 1 Memory Usage: 558kB Buffers: shared hit=147 -> Seq Scan on person person_2 (cost=0.00..159.00 rows=11000 width=8) (actual time=0.011..0.215 rows=11000.00 loops=3) Buffers: shared hit=147 -> Hash (cost=232.80..232.80 rows=16080 width=8) (actual time=1.114..1.114 rows=16080.00 loops=3) Buckets: 16384 Batches: 1 Memory Usage: 757kB
                                             Buffers: shared hit=216
-> Seq Scan on tag (cost=0.00..232.80 rows=16080 width=8) (actual time=0.049..0.379 rows=16080.00 loops=3) Buffers: shared hit=216 -> Parallel Hash (cost=8012.52..8012.52 rows=188106 width=24) (actual time=59.638..59.640 rows=150862.00 loops=3) Buckets: 524288 Batches: 1 Memory Usage: 28928kB
                           Buffers: shared hit=2790
-> Hash Join (cost=593.00..8012.52 rows=188106 width=24) (actual time=1.961..34.498 rows=150862.00 loops=3) Hash Cond: (person_knows_person_3.person1id = person_1.id)
                                 Buffers: shared hit=2790
-> Hash Join (cost=296.50..7222.05 rows=188106 width=16) (actual time=0.745..21.304 rows=150862.00 loops=3) Hash Cond: (person_knows_person_3.person2id = person.id)
                                       Buffers: shared hit=2643
-> Parallel Append (cost=0.00..6431.58 rows=188106 width=16) (actual time=0.012..8.223 rows=150862.00 loops=3)
                                             Buffers: shared hit=2496
-> Parallel Seq Scan on person_knows_person person_knows_person_3 (cost=0.00..2911.92 rows=132448 width=16) (actual time=0.009..3.161 rows=75431.00 loops=3) Filter: (person1id <> person2id) Buffers: shared hit=1248 -> Parallel Seq Scan on person_knows_person (cost=0.00..2579.14 rows=133114 width=16) (actual time=0.009..2.629 rows=113146.50 loops=2) Buffers: shared hit=1248 -> Hash (cost=159.00..159.00 rows=11000 width=8) (actual time=0.684..0.684 rows=11000.00 loops=3) Buckets: 16384 Batches: 1 Memory Usage: 558kB
                                             Buffers: shared hit=147
-> Seq Scan on person (cost=0.00..159.00 rows=11000 width=8) (actual time=0.009..0.205 rows=11000.00 loops=3) Buffers: shared hit=147 -> Hash (cost=159.00..159.00 rows=11000 width=8) (actual time=1.138..1.138 rows=11000.00 loops=3) Buckets: 16384 Batches: 1 Memory Usage: 558kB
                                       Buffers: shared hit=147
-> Seq Scan on person person_1 (cost=0.00..159.00 rows=11000 width=8) (actual time=0.039..0.451 rows=11000.00 loops=3)
                                             Buffers: shared hit=147
 Planning:
   Buffers: shared hit=28
 Planning Time: 6.743 ms
 Execution Time: 39411.297 ms
(74 rows)

Time: 39422.030 ms (00:39.422)

---------------


Q9 SF 1 (where v19beta1, timesout after 30 min):


-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Finalize Aggregate (cost=1958681.23..1958681.24 rows=1 width=8) (actual time=95340.881..95900.581 rows=1.00 loops=1)
   Buffers: shared hit=9366
-> Gather (cost=1958681.01..1958681.22 rows=2 width=8) (actual time=95210.881..95900.488 rows=2.00 loops=1)
         Workers Planned: 1
         Workers Launched: 1
         Buffers: shared hit=9366
-> Partial Aggregate (cost=1957681.01..1957681.02 rows=1 width=8) (actual time=95203.491..95204.497 rows=1.00 loops=2)
               Buffers: shared hit=9366
-> Parallel Hash Join (cost=588976.14..1482881.25 rows=189919907 width=0) (actual time=35680.505..77352.339 rows=798076709.00 loops=2) Hash Cond: (person_hasinterest_tag.personid = person_2.id)
                     Buffers: shared hit=9366
-> Hash Join (cost=433.80..3772.08 rows=150351 width=8) (actual time=6.910..119.288 rows=127798.00 loops=2) Hash Cond: (person_hasinterest_tag.tagid = tag.id)
                           Buffers: shared hit=1584
-> Parallel Seq Scan on person_hasinterest_tag (cost=0.00..2943.51 rows=150351 width=16) (actual time=0.502..50.401 rows=127798.00 loops=2)
                                 Buffers: shared hit=1440
-> Hash (cost=232.80..232.80 rows=16080 width=8) (actual time=5.210..5.211 rows=16080.00 loops=2) Buckets: 16384 Batches: 1 Memory Usage: 757kB
                                 Buffers: shared hit=144
-> Seq Scan on tag (cost=0.00..232.80 rows=16080 width=8) (actual time=1.179..4.232 rows=16080.00 loops=2)
                                       Buffers: shared hit=144
-> Parallel Hash (cost=516172.63..516172.63 rows=5789577 width=16) (actual time=35596.218..35597.107 rows=34241366.00 loops=2) Buckets: 67108864 (originally 16777216) Batches: 1 (originally 1) Memory Usage: 4130784kB
                           Buffers: shared hit=7782
-> Parallel Hash Right Anti Join (cost=178053.88..516172.63 rows=5789577 width=16) (actual time=12849.164..14538.771 rows=34241366.00 loops=2) Hash Cond: ((person_knows_person_2.person1id = person.id) AND (person_knows_person_2.person2id = person_2.id))
                                 Buffers: shared hit=7782
-> Parallel Append (cost=0.00..6101.16 rows=188578 width=16) (actual time=0.357..12.373 rows=226293.00 loops=2)
                                       Buffers: shared hit=2496
-> Parallel Seq Scan on person_knows_person person_knows_person_2 (cost=0.00..2579.14 rows=133114 width=16) (actual time=0.283..7.070 rows=226293.00 loops=1)
                                             Buffers: shared hit=1248
-> Parallel Seq Scan on person_knows_person person_knows_person_3 (cost=0.00..2579.14 rows=133114 width=16) (actual time=0.277..4.205 rows=113146.50 loops=2)
                                             Buffers: shared hit=1248
-> Parallel Hash (cost=62262.35..62262.35 rows=7719435 width=24) (actual time=12193.778..12194.638 rows=35795105.00 loops=2) Buckets: 67108864 (originally 33554432) Batches: 1 (originally 1) Memory Usage: 4709216kB
                                       Buffers: shared hit=5286
-> Parallel Hash Join (cost=10660.34..62262.35 rows=7719435 width=24) (actual time=71.602..2761.515 rows=35795105.00 loops=2) Hash Cond: (person_knows_person_4.person1id = person_1.id) Join Filter: (person.id <> person_2.id) Rows Removed by Join Filter: 226293
                                             Buffers: shared hit=5286
-> Hash Join (cost=296.50..7222.05 rows=188106 width=16) (actual time=0.648..77.824 rows=226293.00 loops=2) Hash Cond: (person_knows_person_4.person2id = person.id) Buffers: shared hit=2594 -> Parallel Append (cost=0.00..6431.58 rows=188106 width=16) (actual time=0.003..30.267 rows=226293.00 loops=2) Buffers: shared hit=2496 -> Parallel Seq Scan on person_knows_person person_knows_person_4 (cost=0.00..2911.92 rows=132448 width=16) (actual time=0.003..34.763 rows=226293.00 loops=1) Filter: (person1id <> person2id) Buffers: shared hit=1248 -> Parallel Seq Scan on person_knows_person (cost=0.00..2579.14 rows=133114 width=16) (actual time=0.150..3.771 rows=113146.50 loops=2) Buffers: shared hit=1248 -> Hash (cost=159.00..159.00 rows=11000 width=8) (actual time=0.594..0.594 rows=11000.00 loops=2) Buckets: 16384 Batches: 1 Memory Usage: 558kB Buffers: shared hit=98 -> Seq Scan on person (cost=0.00..159.00 rows=11000 width=8) (actual time=0.012..0.179 rows=11000.00 loops=2) Buffers: shared hit=98 -> Parallel Hash (cost=8012.52..8012.52 rows=188106 width=32) (actual time=70.230..70.706 rows=226293.00 loops=2) Buckets: 524288 Batches: 1 Memory Usage: 32480kB Buffers: shared hit=2692 -> Hash Join (cost=593.00..8012.52 rows=188106 width=32) (actual time=1.442..41.974 rows=226293.00 loops=2) Hash Cond: (person_knows_person_5.person1id = person_2.id) Buffers: shared hit=2692 -> Hash Join (cost=296.50..7222.05 rows=188106 width=24) (actual time=0.662..25.976 rows=226293.00 loops=2) Hash Cond: (person_knows_person_5.person2id = person_1.id) Buffers: shared hit=2594 -> Parallel Append (cost=0.00..6431.58 rows=188106 width=16) (actual time=0.009..9.680 rows=226293.00 loops=2) Buffers: shared hit=2496 -> Parallel Seq Scan on person_knows_person person_knows_person_5 (cost=0.00..2911.92 rows=132448 width=16) (actual time=0.009..3.833 rows=113146.50 loops=2) Filter: (person1id <> person2id) Buffers: shared hit=1248 -> Parallel Seq Scan on person_knows_person person_knows_person_1 (cost=0.00..2579.14 rows=133114 width=16) (actual time=0.003..4.001 rows=226293.00 loops=1) Buffers: shared hit=1248 -> Hash (cost=159.00..159.00 rows=11000 width=8) (actual time=0.611..0.612 rows=11000.00 loops=2) Buckets: 16384 Batches: 1 Memory Usage: 558kB Buffers: shared hit=98 -> Seq Scan on person person_1 (cost=0.00..159.00 rows=11000 width=8) (actual time=0.007..0.205 rows=11000.00 loops=2) Buffers: shared hit=98 -> Hash (cost=159.00..159.00 rows=11000 width=8) (actual time=0.732..0.758 rows=11000.00 loops=2) Buckets: 16384 Batches: 1 Memory Usage: 558kB Buffers: shared hit=98 -> Seq Scan on person person_2 (cost=0.00..159.00 rows=11000 width=8) (actual time=0.032..0.277 rows=11000.00 loops=2) Buffers: shared hit=98
 Planning:
   Buffers: shared hit=28
 Planning Time: 9.533 ms
 Execution Time: 95907.385 ms
(86 rows)

Time: 95969.259 ms (01:35.969)

---------------

And given just an EXPLAIN on same query in 19beta1 shows how hard it becomes in terms of cost: QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=9075272598.85..9075272598.86 rows=1 width=8)
-> Hash Join (cost=442061350.76..9074461389.95 rows=324483560 width=0)
         Hash Cond: (person_2.id = person_hasinterest_tag.personid)
-> Nested Loop (cost=442053054.93..9068345496.87 rows=13964691 width=8) -> Hash Right Anti Join (cost=442053053.89..983461976.92 rows=3733495045 width=24) Hash Cond: ((person_knows_person_2.person1id = person.id) AND (person_knows_person_2.person2id = person_2.id)) -> Append (cost=0.00..9284.79 rows=452586 width=16) -> Seq Scan on person_knows_person person_knows_person_2 (cost=0.00..3510.93 rows=226293 width=16) -> Seq Scan on person_knows_person person_knows_person_3 (cost=0.00..3510.93 rows=226293 width=16) -> Hash (cost=338215222.00..338215222.00 rows=4977993393 width=24) -> Nested Loop (cost=1.04..338215222.00 rows=4977993393 width=24)
                                 Join Filter: (person.id <> person_2.id)
-> Nested Loop (cost=1.04..263538345.50 rows=452586 width=16) -> Nested Loop (cost=0.00..1512845.50 rows=121000000 width=16) -> Seq Scan on person person_1 (cost=0.00..159.00 rows=11000 width=8) -> Materialize (cost=0.00..214.00 rows=11000 width=8) -> Seq Scan on person person_2 (cost=0.00..159.00 rows=11000 width=8) -> Bitmap Heap Scan on person_knows_person person_knows_person_1 (cost=1.04..2.16 rows=1 width=16) Recheck Cond: (((person_1.id = person1id) AND (person_2.id = person2id)) OR ((person_2.id = person1id) AND (person_1.id = person2id))) -> BitmapOr (cost=1.04..1.04 rows=1 width=0) -> Bitmap Index Scan on person_knows_person_pkey (cost=0.00..0.52 rows=1 width=0) Index Cond: ((person1id = person_1.id) AND (person2id = person_2.id)) -> Bitmap Index Scan on person_knows_person_pkey (cost=0.00..0.52 rows=1 width=0) Index Cond: ((person1id = person_2.id) AND (person2id = person_1.id)) -> Materialize (cost=0.00..214.00 rows=11000 width=8) -> Seq Scan on person (cost=0.00..159.00 rows=11000 width=8) -> Bitmap Heap Scan on person_knows_person (cost=1.04..2.16 rows=1 width=16) Recheck Cond: (((person.id = person1id) AND (person_1.id = person2id)) OR ((person_1.id = person1id) AND (person.id = person2id)))
                     ->  BitmapOr  (cost=1.04..1.04 rows=1 width=0)
-> Bitmap Index Scan on person_knows_person_pkey (cost=0.00..0.52 rows=1 width=0) Index Cond: ((person1id = person.id) AND (person2id = person_1.id)) -> Bitmap Index Scan on person_knows_person_pkey (cost=0.00..0.52 rows=1 width=0) Index Cond: ((person1id = person_1.id) AND (person2id = person.id))
         ->  Hash  (cost=5100.88..5100.88 rows=255596 width=8)
               ->  Hash Join  (cost=433.80..5100.88 rows=255596 width=8)
                     Hash Cond: (person_hasinterest_tag.tagid = tag.id)
-> Seq Scan on person_hasinterest_tag (cost=0.00..3995.96 rows=255596 width=16)
                     ->  Hash  (cost=232.80..232.80 rows=16080 width=8)
-> Seq Scan on tag (cost=0.00..232.80 rows=16080 width=8)
(39 rows)

---------------

I find this a valuable improvement for its cost of implementation, so should be worth it.

What do you think?


Refs:
[1] https://github.com/ldbc/lsqb
[2] https://ir.cwi.nl/pub/31398/31398.pdf

Regards,

Ayoub KAZAR
From a9fd702d108b34cd0885886b88f0397c2bd7df25 Mon Sep 17 00:00:00 2001
From: AyoubKAZ <[email protected]>
Date: Wed, 15 Jul 2026 02:22:56 +0200
Subject: [PATCH] Rewrite undirected edge patterns in GRAPH_TABLE using UNION
 ALL

Previously, undirected edge patterns (-[e]-) were rewritten by combining
the join conditions for both traversal directions into a single OR qual
(src=v1 AND dst=v2 OR src=v2 AND dst=v1). This usually implied using an unefficient BitmapOR scan or the full evaluation of the disjunction, which
becomes particularly costly when intermediate result sets are large, as is
typical in graph queries with undirected edges.

Instead, replace the edge relation with a UNION ALL subquery consisting of
two scans of the edge table: a forward scan that projects columns as-is,
and a backward scan that swaps the source and destination key columns.
This allows the outer query to use a single equi-join to match edges
traversed in either direction.

When the source and destination vertex patterns share the same path factor
(e.g., (p)-[k]-(p)), the forward orientation already satisfies both
directions through a single equi-join, so the UNION ALL is skipped
entirely.

When the UNION ALL is used, a self-loop edge (srckey = destkey) would
appear in both branches and be returned twice, violating the SQL/PGQ
standard.  A NOT(srckey = destkey) filter applied to the backward branch,
built by build_edge_self_loop_filter(), prevents this duplication.
---
 src/backend/rewrite/rewriteGraphTable.c | 336 ++++++++++++++++++++++--
 1 file changed, 310 insertions(+), 26 deletions(-)

diff --git a/src/backend/rewrite/rewriteGraphTable.c b/src/backend/rewrite/rewriteGraphTable.c
index cdb1f4c0dca..28173947927 100644
--- a/src/backend/rewrite/rewriteGraphTable.c
+++ b/src/backend/rewrite/rewriteGraphTable.c
@@ -44,6 +44,7 @@
 #include "utils/lsyscache.h"
 #include "utils/ruleutils.h"
 #include "utils/syscache.h"
+#include "utils/rel.h"
 
 
 /*
@@ -101,6 +102,8 @@ static Query *generate_union_from_pathqueries(List **pathqueries);
 static List *get_path_elements_for_path_factor(Oid propgraphid, struct path_factor *pf);
 static bool is_property_associated_with_label(Oid labeloid, Oid propoid);
 static Node *get_element_property_expr(Oid elemoid, Oid propoid, int rtindex);
+static Query *generate_undirected_edge_union_query(struct path_element *pe);
+static Node *build_edge_self_loop_filter(TupleDesc tupdesc, int nkeys, int16 *src_key_attnums, int16 *dest_key_attnums, Oid *eq_ops);
 
 /*
  * Convert GRAPH_TABLE clause into a subquery using relational
@@ -159,6 +162,16 @@ rewriteGraphTable(Query *parsetree, int rt_index)
  * table on their respective keys. Hence the query representing one path
  * consists of JOINs between edge and vertex tables.
  *
+ * For undirected edge patterns (-[e]-), when the edge's source and destination vertex patterns are different path
+ * factors, but same path element, the edge relation is replaced
+ * by a UNION ALL subquery that duplicates and reverses the key columns, so that
+ * a single equi-join can match edges traversed in either direction.  If the
+ * source and destination vertex patterns share the same path factor (e.g.
+ * (p)-[k]-(p)), either of the two orientations is satisfied by a single equi-join, so the
+ * UNION ALL is skipped.  When the UNION ALL is used, the backward branch
+ * carries a NOT(srckey = destkey) filter so that self-loop edges are not
+ * returned twice.
+ *
  * generate_queries_for_path_pattern() starts the recursion but actual work is
  * done by generate_queries_for_path_pattern_recurse().
  * generate_query_for_graph_path() constructs a query for a given path.
@@ -431,6 +444,7 @@ generate_query_for_graph_path(RangeTblEntry *rte, List *graph_path)
 		RangeTblRef *rtr;
 		Relation	rel;
 		ParseNamespaceItem *pni;
+		bool		use_union_all = false;
 
 		Assert(pf->kind == VERTEX_PATTERN || IS_EDGE_PATTERN(pf->kind));
 
@@ -458,30 +472,32 @@ generate_query_for_graph_path(RangeTblEntry *rte, List *graph_path)
 
 			/*
 			 * An edge pattern in any direction matches edges in both
-			 * directions, try swapping source and destination. When the
-			 * source and destination is the same vertex table, quals
-			 * corresponding to either direction may get satisfied. Hence OR
-			 * the quals corresponding to both the directions.
+			 * directions. When the source and destination is the same vertex
+			 * table, we will replace the edge relation with a UNION ALL
+			 * subquery that duplicates and reverses the edge table keys.
 			 */
 			if (pf->kind == EDGE_PATTERN_ANY &&
 				dest_pe->elemoid == pe->srcvertexid &&
 				src_pe->elemoid == pe->destvertexid)
 			{
-				List	   *src_quals = copyObject(pe->dest_quals);
-				List	   *dest_quals = copyObject(pe->src_quals);
-				Expr	   *rev_edge_qual;
-
-				/* Swap the source and destination varnos in the quals. */
-				ChangeVarNodes((Node *) dest_quals, pe->path_factor->src_pf->factorpos + 1,
-							   pe->path_factor->dest_pf->factorpos + 1, 0);
-				ChangeVarNodes((Node *) src_quals, pe->path_factor->dest_pf->factorpos + 1,
-							   pe->path_factor->src_pf->factorpos + 1, 0);
-
-				rev_edge_qual = makeBoolExpr(AND_EXPR, list_concat(src_quals, dest_quals), -1);
 				if (edge_qual)
-					edge_qual = makeBoolExpr(OR_EXPR, list_make2(edge_qual, rev_edge_qual), -1);
+				{
+					if (pf->src_pf != pf->dest_pf)
+						use_union_all = true;
+				}
 				else
-					edge_qual = rev_edge_qual;
+				{
+					List	   *src_quals = copyObject(pe->dest_quals);
+					List	   *dest_quals = copyObject(pe->src_quals);
+
+					/* Swap the source and destination varnos in the quals. */
+					ChangeVarNodes((Node *) dest_quals, pe->path_factor->src_pf->factorpos + 1,
+								   pe->path_factor->dest_pf->factorpos + 1, 0);
+					ChangeVarNodes((Node *) src_quals, pe->path_factor->dest_pf->factorpos + 1,
+								   pe->path_factor->src_pf->factorpos + 1, 0);
+
+					edge_qual = makeBoolExpr(AND_EXPR, list_concat(src_quals, dest_quals), -1);
+				}
 			}
 
 			/*
@@ -507,13 +523,31 @@ generate_query_for_graph_path(RangeTblEntry *rte, List *graph_path)
 		 * unprivileged data access through a property graph. This is inline
 		 * with the views being security_invoker by default.
 		 */
-		rel = table_open(pe->reloid, AccessShareLock);
-		pni = addRangeTableEntryForRelation(make_parsestate(NULL), rel, AccessShareLock,
-											NULL, true, false);
-		table_close(rel, NoLock);
-		path_query->rtable = lappend(path_query->rtable, pni->p_rte);
-		path_query->rteperminfos = lappend(path_query->rteperminfos, pni->p_perminfo);
-		pni->p_rte->perminfoindex = list_length(path_query->rteperminfos);
+		if (IS_EDGE_PATTERN(pf->kind) && use_union_all)
+		{
+			Query	   *union_query = generate_undirected_edge_union_query(pe);
+
+			pni = addRangeTableEntryForSubquery(make_parsestate(NULL), union_query, makeAlias("edge_union", NIL), true, false);
+			path_query->rtable = lappend(path_query->rtable, pni->p_rte);
+
+			/*
+			 * RTE_SUBQUERY does not have rteperminfo, but relations within
+			 * the subquery already have their perminfos evaluated by
+			 * executor.
+			 */
+
+			pni->p_rte->perminfoindex = 0;
+		}
+		else
+		{
+			rel = table_open(pe->reloid, AccessShareLock);
+			pni = addRangeTableEntryForRelation(make_parsestate(NULL), rel, AccessShareLock,
+												NULL, true, false);
+			table_close(rel, NoLock);
+			path_query->rtable = lappend(path_query->rtable, pni->p_rte);
+			path_query->rteperminfos = lappend(path_query->rteperminfos, pni->p_perminfo);
+			pni->p_rte->perminfoindex = list_length(path_query->rteperminfos);
+		}
 		rtr = makeNode(RangeTblRef);
 		rtr->rtindex = list_length(path_query->rtable);
 		fromlist = lappend(fromlist, rtr);
@@ -562,8 +596,17 @@ generate_query_for_graph_path(RangeTblEntry *rte, List *graph_path)
 	vars = pull_vars_of_level((Node *) list_make2(qual_exprs, path_query->targetList), 0);
 	foreach_node(Var, var, vars)
 	{
-		RTEPermissionInfo *perminfo = getRTEPermissionInfo(path_query->rteperminfos,
-														   rt_fetch(var->varno, path_query->rtable));
+		RangeTblEntry *var_rte = rt_fetch(var->varno, path_query->rtable);
+		RTEPermissionInfo *perminfo;
+
+		/*
+		 * Subqueries handle their own permissions, in this case anything
+		 * below the UNION ALL subquery.
+		 */
+		if (var_rte->rtekind == RTE_SUBQUERY)
+			continue;
+
+		perminfo = getRTEPermissionInfo(path_query->rteperminfos, var_rte);
 
 		/* Must offset the attnum to fit in a bitmapset */
 		perminfo->selectedCols = bms_add_member(perminfo->selectedCols,
@@ -573,6 +616,247 @@ generate_query_for_graph_path(RangeTblEntry *rte, List *graph_path)
 	return path_query;
 }
 
+/*
+ * Construct a UNION ALL subquery over the edge relation that presents each
+ * edge row in both its forward orientation (srckey, destkey) and its backward
+ * orientation (destkey, srckey).  This is used for undirected edge patterns
+ * (-[e]-) when the source and destination vertex patterns are different path
+ * factors.  A NOT(srckey = destkey) filter on the backward branch prevents
+ * self-loop edges from being returned twice.
+ */
+static Query *
+generate_undirected_edge_union_query(struct path_element *pe)
+{
+	Query	   *subquery = makeNode(Query);
+	Query	   *fwd_query = makeNode(Query);
+	Query	   *bwd_query = makeNode(Query);
+	Relation	rel;
+	HeapTuple	eletup;
+	Datum		datum;
+	Datum	   *d1,
+			   *d2;
+	int			n1,
+				n2;
+	int16	   *src_key_attnums;
+	int16	   *dest_key_attnums;
+	Oid		   *eq_ops;
+	ParseNamespaceItem *fwd_pni;
+	ParseNamespaceItem *bwd_pni;
+	SetOperationStmt *sostmt;
+	TupleDesc	tupdesc;
+	RangeTblRef *fwd_rtr;
+	RangeTblRef *bwd_rtr;
+
+	subquery->commandType = CMD_SELECT;
+	fwd_query->commandType = CMD_SELECT;
+	bwd_query->commandType = CMD_SELECT;
+
+	rel = table_open(pe->reloid, AccessShareLock);
+
+	eletup = SearchSysCache1(PROPGRAPHELOID, ObjectIdGetDatum(pe->elemoid));
+	if (!eletup)
+		elog(ERROR, "cache lookup failed for property graph element %u", pe->elemoid);
+
+	datum = SysCacheGetAttrNotNull(PROPGRAPHELOID, eletup, Anum_pg_propgraph_element_pgesrckey);
+	deconstruct_array_builtin(DatumGetArrayTypeP(datum), INT2OID, &d1, NULL, &n1);
+	datum = SysCacheGetAttrNotNull(PROPGRAPHELOID, eletup, Anum_pg_propgraph_element_pgedestkey);
+	deconstruct_array_builtin(DatumGetArrayTypeP(datum), INT2OID, &d2, NULL, &n2);
+
+	{
+		Datum	   *d3;
+		int			n3;
+
+		datum = SysCacheGetAttrNotNull(PROPGRAPHELOID, eletup, Anum_pg_propgraph_element_pgesrceqop);
+		deconstruct_array_builtin(DatumGetArrayTypeP(datum), OIDOID, &d3, NULL, &n3);
+		Assert(n1 == n3);
+		eq_ops = palloc(n1 * sizeof(Oid));
+		for (int i = 0; i < n1; i++)
+			eq_ops[i] = DatumGetObjectId(d3[i]);
+	}
+
+	Assert(n1 == n2);
+	src_key_attnums = palloc(n1 * sizeof(int16));
+	dest_key_attnums = palloc(n1 * sizeof(int16));
+	for (int i = 0; i < n1; i++)
+	{
+		src_key_attnums[i] = DatumGetInt16(d1[i]);
+		dest_key_attnums[i] = DatumGetInt16(d2[i]);
+	}
+
+	ReleaseSysCache(eletup);
+
+	fwd_pni = addRangeTableEntryForRelation(make_parsestate(NULL), rel, AccessShareLock, NULL, true, false);
+	fwd_query->rtable = list_make1(fwd_pni->p_rte);
+	fwd_query->rteperminfos = list_make1(fwd_pni->p_perminfo);
+	fwd_pni->p_rte->perminfoindex = 1;
+	{
+		RangeTblRef *rtr = makeNode(RangeTblRef);
+
+		rtr->rtindex = 1;
+		fwd_query->jointree = makeFromExpr(list_make1(rtr), NULL);
+	}
+
+	bwd_pni = addRangeTableEntryForRelation(make_parsestate(NULL), rel, AccessShareLock, NULL, true, false);
+	bwd_query->rtable = list_make1(bwd_pni->p_rte);
+	bwd_query->rteperminfos = list_make1(bwd_pni->p_perminfo);
+	bwd_pni->p_rte->perminfoindex = 1;
+	{
+		RangeTblRef *rtr = makeNode(RangeTblRef);
+
+		rtr->rtindex = 1;
+		bwd_query->jointree = makeFromExpr(list_make1(rtr), NULL);
+	}
+
+	sostmt = makeNode(SetOperationStmt);
+	sostmt->op = SETOP_UNION;
+	sostmt->all = true;
+
+	tupdesc = RelationGetDescr(rel);
+
+	bwd_query->jointree->quals = build_edge_self_loop_filter(tupdesc, n1, src_key_attnums, dest_key_attnums, eq_ops);
+
+	for (int i = 0; i < tupdesc->natts; i++)
+	{
+		Form_pg_attribute att = TupleDescAttr(tupdesc, i);
+		TargetEntry *tle1,
+				   *tle2,
+				   *out_tle;
+		Var		   *fwd_var,
+				   *bwd_var,
+				   *out_var;
+		int			bwd_attnum;
+		Form_pg_attribute bwd_att;
+
+		if (att->attisdropped)
+		{
+			Node	   *null_expr = (Node *) makeNullConst(att->atttypid, att->atttypmod, att->attcollation);
+
+			tle1 = makeTargetEntry((Expr *) null_expr, i + 1, pstrdup(""), false);
+			fwd_query->targetList = lappend(fwd_query->targetList, tle1);
+
+			tle2 = makeTargetEntry((Expr *) copyObject(null_expr), i + 1, pstrdup(""), false);
+			bwd_query->targetList = lappend(bwd_query->targetList, tle2);
+		}
+		else
+		{
+			RTEPermissionInfo *fwd_perminfo = getRTEPermissionInfo(fwd_query->rteperminfos, fwd_pni->p_rte);
+			RTEPermissionInfo *bwd_perminfo = getRTEPermissionInfo(bwd_query->rteperminfos, bwd_pni->p_rte);
+
+			/*
+			 * Mark each column as selected in both perminfos.  The backward
+			 * query reads bwd_attnum rather than att->attnum, since key
+			 * columns are swapped; non-key columns are identical in both.
+			 */
+			fwd_var = makeVar(1, att->attnum, att->atttypid, att->atttypmod, att->attcollation, 0);
+			tle1 = makeTargetEntry((Expr *) fwd_var, i + 1, pstrdup(NameStr(att->attname)), false);
+			fwd_query->targetList = lappend(fwd_query->targetList, tle1);
+			fwd_perminfo->selectedCols = bms_add_member(fwd_perminfo->selectedCols,
+														att->attnum - FirstLowInvalidHeapAttributeNumber);
+
+			bwd_attnum = att->attnum;
+			for (int k = 0; k < n1; k++)
+			{
+				if (src_key_attnums[k] == att->attnum)
+					bwd_attnum = dest_key_attnums[k];
+				else if (dest_key_attnums[k] == att->attnum)
+					bwd_attnum = src_key_attnums[k];
+			}
+			bwd_att = TupleDescAttr(tupdesc, bwd_attnum - 1);
+			bwd_var = makeVar(1, bwd_attnum, bwd_att->atttypid, bwd_att->atttypmod, bwd_att->attcollation, 0);
+			tle2 = makeTargetEntry((Expr *) bwd_var, i + 1, pstrdup(NameStr(att->attname)), false);
+			bwd_query->targetList = lappend(bwd_query->targetList, tle2);
+			bwd_perminfo->selectedCols = bms_add_member(bwd_perminfo->selectedCols,
+														bwd_attnum - FirstLowInvalidHeapAttributeNumber);
+		}
+
+		sostmt->colTypes = lappend_oid(sostmt->colTypes, att->atttypid);
+		sostmt->colTypmods = lappend_int(sostmt->colTypmods, att->atttypmod);
+		sostmt->colCollations = lappend_oid(sostmt->colCollations, att->attcollation);
+
+		out_var = makeVar(1, i + 1, att->atttypid, att->atttypmod, att->attcollation, 0);
+		out_tle = makeTargetEntry((Expr *) out_var, i + 1, pstrdup(NameStr(att->attname)), false);
+		subquery->targetList = lappend(subquery->targetList, out_tle);
+	}
+
+	table_close(rel, NoLock);
+
+	fwd_rtr = makeNode(RangeTblRef);
+	fwd_rtr->rtindex = 1;
+	bwd_rtr = makeNode(RangeTblRef);
+	bwd_rtr->rtindex = 2;
+	sostmt->larg = (Node *) fwd_rtr;
+	sostmt->rarg = (Node *) bwd_rtr;
+
+	fwd_pni = addRangeTableEntryForSubquery(make_parsestate(NULL), fwd_query, NULL, false, false);
+	bwd_pni = addRangeTableEntryForSubquery(make_parsestate(NULL), bwd_query, NULL, false, false);
+
+	subquery->rtable = list_make2(fwd_pni->p_rte, bwd_pni->p_rte);
+	subquery->setOperations = (Node *) sostmt;
+	subquery->jointree = makeFromExpr(NIL, NULL);
+
+	return subquery;
+}
+
+/*
+ * Generate a qualification expression to filter out self-loop edges.
+ */
+static Node *
+build_edge_self_loop_filter(TupleDesc tupdesc, int nkeys,
+							int16 *src_key_attnums, int16 *dest_key_attnums,
+							Oid *eq_ops)
+{
+	List	   *quals = NIL;
+	Expr	   *eq_all;
+	Node	   *qual;
+	ParseState *pstate = make_parsestate(NULL);
+
+	for (int i = 0; i < nkeys; i++)
+	{
+		Form_pg_attribute src_att = TupleDescAttr(tupdesc, src_key_attnums[i] - 1);
+		Form_pg_attribute dest_att = TupleDescAttr(tupdesc, dest_key_attnums[i] - 1);
+		Var		   *src_var = makeVar(1, src_key_attnums[i], src_att->atttypid, src_att->atttypmod, src_att->attcollation, 0);
+		Var		   *dest_var = makeVar(1, dest_key_attnums[i], dest_att->atttypid, dest_att->atttypmod, dest_att->attcollation, 0);
+		List	   *args;
+		Oid			actual_arg_types[2];
+		Oid			declared_arg_types[2];
+		HeapTuple	tup;
+		Form_pg_operator opform;
+		OpExpr	   *linkqual;
+
+		tup = SearchSysCache1(OPEROID, ObjectIdGetDatum(eq_ops[i]));
+		if (!HeapTupleIsValid(tup))
+			elog(ERROR, "cache lookup failed for operator %u", eq_ops[i]);
+		opform = (Form_pg_operator) GETSTRUCT(tup);
+
+		args = list_make2(src_var, dest_var);
+		actual_arg_types[0] = exprType((Node *) src_var);
+		actual_arg_types[1] = exprType((Node *) dest_var);
+		declared_arg_types[0] = opform->oprleft;
+		declared_arg_types[1] = opform->oprright;
+		make_fn_arguments(pstate, args, actual_arg_types, declared_arg_types);
+
+		linkqual = makeNode(OpExpr);
+		linkqual->opno = opform->oid;
+		linkqual->opfuncid = opform->oprcode;
+		linkqual->opresulttype = opform->oprresult;
+		linkqual->opretset = false;
+		/* opcollid and inputcollid will be set by parse_collate.c */
+		linkqual->args = args;
+		linkqual->location = -1;
+
+		ReleaseSysCache(tup);
+		quals = lappend(quals, linkqual);
+	}
+
+	eq_all = (list_length(quals) > 1
+			  ? (Expr *) makeBoolExpr(AND_EXPR, quals, -1)
+			  : (Expr *) linitial(quals));
+	qual = (Node *) makeBoolExpr(NOT_EXPR, list_make1(eq_all), -1);
+
+	assign_expr_collations(pstate, qual);
+	return qual;
+}
+
 /*
  * Construct a query which would not return any rows.
  *
-- 
2.34.1

Reply via email to