pg_freespacemap \
pg_logicalinspect \
pg_overexplain \
+ pg_plan_advice \
pg_prewarm \
pg_stat_statements \
pg_surgery \
subdir('pg_freespacemap')
subdir('pg_logicalinspect')
subdir('pg_overexplain')
+subdir('pg_plan_advice')
subdir('pg_prewarm')
subdir('pgrowlocks')
subdir('pg_stat_statements')
--- /dev/null
+/pgpa_parser.h
+/pgpa_parser.c
+/pgpa_scanner.c
--- /dev/null
+# contrib/pg_plan_advice/Makefile
+
+MODULE_big = pg_plan_advice
+OBJS = \
+ $(WIN32RES) \
+ pg_plan_advice.o \
+ pgpa_ast.o \
+ pgpa_collector.o \
+ pgpa_identifier.o \
+ pgpa_join.o \
+ pgpa_output.o \
+ pgpa_parser.o \
+ pgpa_planner.o \
+ pgpa_scan.o \
+ pgpa_scanner.o \
+ pgpa_trove.o \
+ pgpa_walker.o
+
+EXTENSION = pg_plan_advice
+DATA = pg_plan_advice--1.0.sql
+PGFILEDESC = "pg_plan_advice - help the planner get the right plan"
+
+REGRESS = gather join_order join_strategy partitionwise scan
+TAP_TESTS = 1
+
+EXTRA_CLEAN = pgpa_parser.h pgpa_parser.c pgpa_scanner.c
+
+# required for 001_regress.pl
+REGRESS_SHLIB=$(abs_top_builddir)/src/test/regress/regress$(DLSUFFIX)
+export REGRESS_SHLIB
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = contrib/pg_plan_advice
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
+
+# See notes in src/backend/parser/Makefile about the following two rules
+pgpa_parser.h: pgpa_parser.c
+ touch $@
+
+pgpa_parser.c: BISONFLAGS += -d
+
+# Force these dependencies to be known even without dependency info built:
+pgpa_parser.o pgpa_scanner.o: pgpa_parser.h
--- /dev/null
+contrib/pg_plan_advice/README
+
+Plan Advice
+===========
+
+This module implements a mini-language for "plan advice" that allows for
+control of certain key planner decisions. Goals include (1) enforcing plan
+stability (my previous plan was good and I would like to keep getting a
+similar one) and (2) allowing users to experiment with plans other than
+the one preferred by the optimizer. Non-goals include (1) controlling
+every possible planner decision and (2) forcing consideration of plans
+that the optimizer rejects for reasons other than cost. (There is some
+room for bikeshedding about what exactly this non-goal means: what if
+we skip path generation entirely for a certain case on the theory that
+we know it cannot win on cost? Does that count as a cost-based rejection
+even though no cost was ever computed?)
+
+Generally, plan advice is a series of whitespace-separated advice items,
+each of which applies an advice tag to a list of advice targets. For
+example, "SEQ_SCAN(foo) HASH_JOIN(bar@ss)" contains two items of advice,
+the first of which applies the SEQ_SCAN tag to "foo" and the second of
+which applies the HASH_JOIN tag to "bar@ss". In this simple example, each
+target identifies a single relation; see "Relation Identifiers", below.
+Advice tags can also be applied to groups of relations; for example,
+"HASH_JOIN(baz (bletch quux))" applies the HASH_JOIN tag to the single
+relation identifier "baz" as well as to the 2-item list containing
+"bletch" and "quux".
+
+Critically, this module knows both how to generate plan advice from an
+already-existing plan, and also how to enforce it during future planning
+cycles. Everything it does is intended to be "round-trip safe": if you
+generate advice from a plan and then feed that back into a future planing
+cycle, each piece of advice should be guaranteed to apply to the exactly the
+same part of the query from which it was generated without ambiguity or
+guesswork, and it should succesfully enforce the same planning decision that
+led to it being generated in the first place. Note that there is no
+intention that these guarantees hold in the presence of intervening DDL;
+e.g. if you change the properties of a function so that a subquery is no
+longer inlined, or if you drop an index named in the plan advice, the advice
+isn't going to work any more. That's expected.
+
+This module aims to force the planner to follow any provided advice without
+regard to whether it is appears to be good advice or bad advice. If the
+user provides bad advice, whether derived from a previously-generated plan
+or manually written, they may get a bad plan. We regard this as user error,
+not a defect in this module. It seems likely that applying advice
+judiciously and only when truly required to avoid problems will be a more
+successful strategy than applying it with a broad brush, but users are free
+to experiment with whatever strategies they think best.
+
+Relation Identifiers
+====================
+
+Uniquely identifying the part of a query to which a certain piece of
+advice applies is harder than it sounds. Our basic approach is to use
+relation aliases as a starting point, and then disambiguate. There are
+three ways that same relation alias can occur multiple times:
+
+1. It can appear in more than one subquery.
+
+2. It can appear more than once in the same subquery,
+ e.g. (foo JOIN bar) x JOIN foo.
+
+3. The table can be partitioned.
+
+Any combination of these things can occur simultaneously. Therefore, our
+general syntax for a relation identifier is:
+
+alias_name#occurrence_number/partition_schema.partition_name@plan_name
+
+All components except for the alias_name are optional and included only
+when required. When a component is omitted, the associated punctuation
+must also be omitted. Occurrence numbers are counted ignoring children of
+partitioned tables. When the generated occurrence number is 1, we omit
+the occurrence number. The partition schema and partition name are included
+only for children of partitioned tables. In generated advice, the
+partition_schema is always included whenever there is a partition_name,
+but user-written advice may mention the name and omit the schema. The
+plan_name is omitted for the top-level PlannerInfo.
+
+Scan Advice
+===========
+
+For many types of scan, no advice is generated or possible; for instance,
+a subquery is always scanned using a subquery scan. While that scan may be
+elided via setrefs processing, this doesn't change the fact that only one
+basic approach exists. Hence, scan advice applies mostly to relations, which
+can be scanned in multiple ways.
+
+We tend to think of a scan as targeting a single relation, and that's
+normally the case, but it doesn't have to be. For instance, if a join is
+proven empty, the whole thing may be replaced with a single Result node
+which, in effect, is a degenerate scan of every relation in the collapsed
+portion of the join tree. Similarly, it's possible to inject a custom scan
+in such a way that it replaces an entire join. If we ever emit advice
+for these cases, it would target sets of relation identifiers surrounded
+by curly brances, e.g. SOME_SORT_OF_SCAN(foo (bar baz)) would mean that the
+the given scan type would be used for foo as a single relation and also the
+combination of bar and baz as a join product. We have no such cases at
+present.
+
+For index and index-only scans, both the relation being scanned and the
+index or indexes being used must be specified. For example, INDEX_SCAN(foo
+foo_a_idx bar bar_b_idx) indicates that an index scan (not an index-only
+scan) should be used on foo_a_idx when scanning foo, and that an index scan
+should be used on bar_b_idx when scanning bar.
+
+Bitmap heap scans allow for a more complicated index specification. For
+example, BITMAP_HEAP_SCAN(foo &&(foo_a_idx ||(foo_b_idx foo_c_idx))) says
+that foo should be scanned using a BitmapHeapScan over a BitmapAnd between
+foo_a_idx and the result of a BitmapOr between foo_b_idx and foo_c_idx.
+
+XXX: Currently, BITMAP_HEAP_SCAN does not enforce the index specification,
+because the available hooks are insufficient to do so. It's possible that
+this should be changed to exclude the index specification altogether and
+simply insist that some sort of bitmap heap scan is used; alternatively,
+we need better hooks.
+
+Join Order Advice
+=================
+
+The JOIN_ORDER tag specifies the order in which several tables that are
+part of the same join problem should be joined. Each subquery (except for
+those that are inlined) is a separate join problem. Within a subquery,
+partitionwise joins can create additional, separate join problems. Hence,
+queries involving partitionwise joins may use JOIN_ORDER() many times.
+
+We take the canonical join structure to be an outer-deep tree, so
+JOIN_ORDER(t1 t2 t3) says that t1 is the driving table and should be joined
+first to t2 and then to t3. If the join problem involves additional tables,
+they can be joined in any order after the join between t1, t2, and t3 has
+been constructured. Generated join advice always mentions all tables
+in the join problem, but manually written join advice need not do so.
+
+For trees which are not outer-deep, parentheses can be used. For example,
+JOIN_ORDER(t1 (t2 t3)) says that the top-level join should have t1 on the
+outer side and a join between t2 and t3 on the inner side. That join should
+be constructed so that t2 is on the outer side and t3 is on the inner side.
+
+In some cases, it's not possible to fully specify the join order in this way.
+For example, if t2 and t3 are being scanned by a single custom scan or foreign
+scan, or if a partitionwise join is being performed between those tables, then
+it's impossible to say that t2 is the outer table and t3 is the inner table,
+or the other way around; it's just undefined. In such cases, we generate
+join advice that uses curly braces, intending to indicate a lack of ordering:
+JOIN_ORDER(t1 {t2 t3}) says that the uppermost join should have t1 on the outer
+side and some kind of join between t2 and t3 on the inner side, but without
+saying how that join must be performed or anything about which relation should
+appear on which side of the join, or even whether this kind of join has sides.
+
+Join Strategy Advice
+====================
+
+Tags such as NESTED_LOOP_PLAIN specify the method that should be used to
+perform a certain join. More specifically, NESTED_LOOP_PLAIN(x (y z)) says
+that the plan should put the relation whose identifier is "x" on the inner
+side of a plain nested loop (one without materialization or memoization)
+and that it should also put a join between the relation whose identifier is
+"y" and the relation whose identifier is "z" on the inner side of a nested
+loop. Hence, for an N-table join problem, there will be N-1 pieces of join
+strategy advice; no join strategy advice is required for the outermost
+table in the join problem.
+
+Considering that we have both join order advice and join strategy advice,
+it might seem natural to say that NESTED_LOOP_PLAIN(x) should be redefined
+to mean that x should appear by itself on one side or the other of a nested
+loop, rather than specifically on the inner side, but this definition appears
+useless in practice. It gives the planner too much freedom to do things that
+bear little resemblance to what the user probably had in mind. This makes
+only a limited amount of practical difference in the case of a merge join or
+unparameterized nested loop, but for a parameterized nested loop or a hash
+join, the two sides are treated very differently and saying that a certain
+relation should be involved in one of those operations without saying which
+role it should take isn't saying much.
+
+This choice of definition implies that join strategy advice also imposes some
+join order constraints. For example, given a join between foo and bar,
+HASH_JOIN(bar) implies that foo is the driving table. Otherwise, it would
+be impossible to put bar beneath the inner side of a Hash Join.
+
+Note that, given this definition, it's reasonable to consider deleting the
+join order advice but applying the join strategy advice. For example,
+consider a star schema with tables fact, dim1, dim2, dim3, dim4, and dim5.
+The automatically generated advice might specify JOIN_ORDER(fact dim1 dim3
+dim4 dim2 dim5) HASH_JOIN(dim2 dim4) NESTED_LOOP_PLAIN(dim1 dim3 dim5).
+Deleting the JOIN_ORDER advice allows the planner to reorder the joins
+however it likes while still forcing the same choice of join method. This
+seems potentially useful, and is one reason why a unified syntax that controls
+both join order and join method in a single locution was not chosen.
+
+Advice Completeness
+===================
+
+An essential guiding principle is that no inference may made on the basis
+of the absence of advice. The user is entitled to remove any portion of the
+generated advice which they deem unsuitable or counterproductive and the
+result should only be to increase the flexibility afforded to the planner.
+This means that if advice can say that a certain optimization or technique
+should be used, it should also be able to say that the optimization or
+technique should not be used. We should never assume that the absence of an
+instruction to do a certain thing means that it should not be done; all
+instructions must be explicit.
+
+Semijoin Uniqueness
+===================
+
+Faced with a semijoin, the planner considers both a direct implementation
+and a plan where the one side is made unique and then an inner join is
+performed. We emit SEMIJOIN_UNIQUE() advice when this transformation occurs
+and SEMIJOIN_NON_UNIQUE() advice when it doesn't. These items work like
+join strategy advice: the inner side of the relevant join is named, and the
+chosen join order must be compatible with the advice having some effect.
+
+XXX: Currently, SEMIJOIN_NON_UNIQUE() advice is emitted in some situations
+where the SEMIJOIN_UNIQUE() approach was determined to be non-viable; ideally,
+we should avoid that.
+
+XXX: Right semijoins haven't been properly thought through. The associated
+code probably just doesn't work.
+
+XXX: Semijoin uniqueness advice has no automated tests and need substantially
+more manual testing.
+
+Partitionwise
+=============
+
+PARTITIONWISE() advise can be used to specify both those partitionwise joins
+which should be performed and those which should not be performed; the idea
+is that each argument to PARTITIONWISE specifies a set of relations that
+should be scanned partitionwise after being joined to each other and nothing
+else. Hence, for example, PARTITIONWISE((t1 t2) t3) specifies that the
+query should contain a partitionwise join between t1 and t2 and that t3
+should not be part of any partitionwise join. If there are no other rels
+in the query, specifying just PARTITIONWISE((t1 t2)) would have the same
+effect, since there would be no other rels to which t3 could be joined in
+a partitionwise fashion.
+
+Parallel Query (Gather, etc.)
+=============================
+
+Each argument to GATHER() or GATHER_MERGE() is a single relation or an
+exact set of relations on top of which a Gather or Gather Merge node,
+respectively, should be placed. Each argument to NO_GATHER() is a single
+relation that should not appear beneath any Gather or Gather Merge node;
+that is, parallelism should not be used.
+
+Implicit Join Order Constraints
+===============================
+
+When JOIN_ORDER() advice is not provided for a particular join problem,
+other pieces of advice may still incidentally constraint the join order.
+For example, a user who specifies HASH_JOIN((foo bar)) is explicitly saying
+that there should be a hash join with exactly foo and bar on the outer
+side of it, but that also implies that foo and bar must be joined to
+each other before either of them is joined to anything else. Otherwise,
+the join the user is attempting to constraint won't actually occur in the
+query, which ends up looking like the system has just decided to ignore
+the advice altogether.
+
+Future Work
+===========
+
+We don't handle choice of aggregation: it would be nice to be able to force
+sorted or grouped aggregation. I'm guessing this can be left to future work.
+
+More seriously, we don't know anything about eager aggregation, which could
+have a large impact on the shape of the plan tree. XXX: This needs some study
+to determine how large a problem it is, and might need to be fixed sooner
+rather than later.
+
+We don't offer any control over estimates, only outcomes. It seems like a
+good idea to incorporate that ability at some future point, as pg_hint_plan
+does. However, since primary goal of the initial development work is to be
+able to induce the planner to recreate a desired plan that worked well in
+the past, this has not been included in the initial development effort.
--- /dev/null
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 1;
+SET parallel_setup_cost = 0;
+SET parallel_tuple_cost = 0;
+SET min_parallel_table_scan_size = 0;
+SET debug_parallel_query = off;
+CREATE TABLE gt_dim (id serial primary key, dim text)
+ WITH (autovacuum_enabled = false);
+INSERT INTO gt_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE gt_dim;
+CREATE TABLE gt_fact (
+ id int not null,
+ dim_id integer not null references gt_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO gt_fact
+ SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE gt_fact;
+-- By default, we expect Gather Merge with a parallel hash join.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+ QUERY PLAN
+-------------------------------------------------------
+ Gather Merge
+ Workers Planned: 1
+ -> Sort
+ Sort Key: f.dim_id
+ -> Parallel Hash Join
+ Hash Cond: (f.dim_id = d.id)
+ -> Parallel Seq Scan on gt_fact f
+ -> Parallel Hash
+ -> Parallel Seq Scan on gt_dim d
+ Generated Plan Advice:
+ JOIN_ORDER(f d)
+ HASH_JOIN(d)
+ SEQ_SCAN(f d)
+ GATHER_MERGE((f d))
+(14 rows)
+
+-- Force Gather or Gather Merge of both relations together.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+ QUERY PLAN
+-------------------------------------------------------
+ Gather Merge
+ Workers Planned: 1
+ -> Sort
+ Sort Key: f.dim_id
+ -> Parallel Hash Join
+ Hash Cond: (f.dim_id = d.id)
+ -> Parallel Seq Scan on gt_fact f
+ -> Parallel Hash
+ -> Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+ GATHER_MERGE((f d)) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(f d)
+ HASH_JOIN(d)
+ SEQ_SCAN(f d)
+ GATHER_MERGE((f d))
+(16 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+ QUERY PLAN
+-------------------------------------------------------
+ Sort
+ Sort Key: f.dim_id
+ -> Gather
+ Workers Planned: 1
+ -> Parallel Hash Join
+ Hash Cond: (f.dim_id = d.id)
+ -> Parallel Seq Scan on gt_fact f
+ -> Parallel Hash
+ -> Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+ GATHER((f d)) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(f d)
+ HASH_JOIN(d)
+ SEQ_SCAN(f d)
+ GATHER((f d))
+(16 rows)
+
+COMMIT;
+-- Force a separate Gather or Gather Merge operation for each relation.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+ QUERY PLAN
+--------------------------------------------------
+ Merge Join
+ Merge Cond: (f.dim_id = d.id)
+ -> Gather Merge
+ Workers Planned: 1
+ -> Sort
+ Sort Key: f.dim_id
+ -> Parallel Seq Scan on gt_fact f
+ -> Gather Merge
+ Workers Planned: 1
+ -> Sort
+ Sort Key: d.id
+ -> Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+ GATHER_MERGE(f) /* matched */
+ GATHER_MERGE(d) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(f d)
+ MERGE_JOIN_PLAIN(d)
+ SEQ_SCAN(f d)
+ GATHER_MERGE(f d)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+ QUERY PLAN
+--------------------------------------------------
+ Merge Join
+ Merge Cond: (f.dim_id = d.id)
+ -> Sort
+ Sort Key: f.dim_id
+ -> Gather
+ Workers Planned: 1
+ -> Parallel Seq Scan on gt_fact f
+ -> Sort
+ Sort Key: d.id
+ -> Gather
+ Workers Planned: 1
+ -> Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+ GATHER(f) /* matched */
+ GATHER(d) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(f d)
+ MERGE_JOIN_PLAIN(d)
+ SEQ_SCAN(f d)
+ GATHER(f d)
+(20 rows)
+
+COMMIT;
+-- Force a Gather or Gather Merge on one relation but no parallelism on other.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+ QUERY PLAN
+--------------------------------------------------
+ Merge Join
+ Merge Cond: (f.dim_id = d.id)
+ -> Gather Merge
+ Workers Planned: 1
+ -> Sort
+ Sort Key: f.dim_id
+ -> Parallel Seq Scan on gt_fact f
+ -> Index Scan using gt_dim_pkey on gt_dim d
+ Supplied Plan Advice:
+ GATHER_MERGE(f) /* matched */
+ NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(f d)
+ MERGE_JOIN_PLAIN(d)
+ SEQ_SCAN(f)
+ INDEX_SCAN(d public.gt_dim_pkey)
+ GATHER_MERGE(f)
+ NO_GATHER(d)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather_merge(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+ QUERY PLAN
+-------------------------------------------------
+ Merge Join
+ Merge Cond: (f.dim_id = d.id)
+ -> Sort
+ Sort Key: f.dim_id
+ -> Seq Scan on gt_fact f
+ -> Gather Merge
+ Workers Planned: 1
+ -> Sort
+ Sort Key: d.id
+ -> Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+ GATHER_MERGE(d) /* matched */
+ NO_GATHER(f) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(f d)
+ MERGE_JOIN_PLAIN(d)
+ SEQ_SCAN(f d)
+ GATHER_MERGE(d)
+ NO_GATHER(f)
+(19 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(f) no_gather(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+ QUERY PLAN
+--------------------------------------------------
+ Merge Join
+ Merge Cond: (d.id = f.dim_id)
+ -> Index Scan using gt_dim_pkey on gt_dim d
+ -> Sort
+ Sort Key: f.dim_id
+ -> Gather
+ Workers Planned: 1
+ -> Parallel Seq Scan on gt_fact f
+ Supplied Plan Advice:
+ GATHER(f) /* matched */
+ NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(d f)
+ MERGE_JOIN_PLAIN(f)
+ SEQ_SCAN(f)
+ INDEX_SCAN(d public.gt_dim_pkey)
+ GATHER(f)
+ NO_GATHER(d)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather(d) no_gather(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+ QUERY PLAN
+-------------------------------------------------
+ Merge Join
+ Merge Cond: (f.dim_id = d.id)
+ -> Sort
+ Sort Key: f.dim_id
+ -> Seq Scan on gt_fact f
+ -> Sort
+ Sort Key: d.id
+ -> Gather
+ Workers Planned: 1
+ -> Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+ GATHER(d) /* matched */
+ NO_GATHER(f) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(f d)
+ MERGE_JOIN_PLAIN(d)
+ SEQ_SCAN(f d)
+ GATHER(d)
+ NO_GATHER(f)
+(19 rows)
+
+COMMIT;
+-- Force no Gather or Gather Merge use at all.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'no_gather(f d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
+ QUERY PLAN
+------------------------------------------------
+ Merge Join
+ Merge Cond: (d.id = f.dim_id)
+ -> Index Scan using gt_dim_pkey on gt_dim d
+ -> Sort
+ Sort Key: f.dim_id
+ -> Seq Scan on gt_fact f
+ Supplied Plan Advice:
+ NO_GATHER(f) /* matched */
+ NO_GATHER(d) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(d f)
+ MERGE_JOIN_PLAIN(f)
+ SEQ_SCAN(f)
+ INDEX_SCAN(d public.gt_dim_pkey)
+ NO_GATHER(f d)
+(15 rows)
+
+COMMIT;
+-- Can't force Gather Merge without the ORDER BY clause, but just Gather is OK.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'gather_merge((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+ QUERY PLAN
+-------------------------------------------------
+ Gather
+ Disabled: true
+ Workers Planned: 1
+ -> Parallel Hash Join
+ Hash Cond: (f.dim_id = d.id)
+ -> Parallel Seq Scan on gt_fact f
+ -> Parallel Hash
+ -> Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+ GATHER_MERGE((f d)) /* matched, failed */
+ Generated Plan Advice:
+ JOIN_ORDER(f d)
+ HASH_JOIN(d)
+ SEQ_SCAN(f d)
+ GATHER((f d))
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'gather((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id;
+ QUERY PLAN
+-------------------------------------------------
+ Gather
+ Workers Planned: 1
+ -> Parallel Hash Join
+ Hash Cond: (f.dim_id = d.id)
+ -> Parallel Seq Scan on gt_fact f
+ -> Parallel Hash
+ -> Parallel Seq Scan on gt_dim d
+ Supplied Plan Advice:
+ GATHER((f d)) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(f d)
+ HASH_JOIN(d)
+ SEQ_SCAN(f d)
+ GATHER((f d))
+(14 rows)
+
+COMMIT;
--- /dev/null
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE jo_dim1 (id integer primary key, dim1 text, val1 int)
+ WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim1 (id, dim1, val1)
+ SELECT g, 'some filler text ' || g, (g % 3) + 1
+ FROM generate_series(1,100) g;
+VACUUM ANALYZE jo_dim1;
+CREATE TABLE jo_dim2 (id integer primary key, dim2 text, val2 int)
+ WITH (autovacuum_enabled = false);
+INSERT INTO jo_dim2 (id, dim2, val2)
+ SELECT g, 'some filler text ' || g, (g % 7) + 1
+ FROM generate_series(1,1000) g;
+VACUUM ANALYZE jo_dim2;
+CREATE TABLE jo_fact (
+ id int primary key,
+ dim1_id integer not null references jo_dim1 (id),
+ dim2_id integer not null references jo_dim2 (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO jo_fact
+ SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g;
+VACUUM ANALYZE jo_fact;
+-- We expect to join to d2 first and then d1, since the condition on d2
+-- is more selective.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+ LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+ LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+ WHERE val1 = 1 AND val2 = 1;
+ QUERY PLAN
+------------------------------------------
+ Hash Join
+ Hash Cond: (f.dim1_id = d1.id)
+ -> Hash Join
+ Hash Cond: (f.dim2_id = d2.id)
+ -> Seq Scan on jo_fact f
+ -> Hash
+ -> Seq Scan on jo_dim2 d2
+ Filter: (val2 = 1)
+ -> Hash
+ -> Seq Scan on jo_dim1 d1
+ Filter: (val1 = 1)
+ Generated Plan Advice:
+ JOIN_ORDER(f d2 d1)
+ HASH_JOIN(d2 d1)
+ SEQ_SCAN(f d2 d1)
+ NO_GATHER(f d1 d2)
+(16 rows)
+
+-- Force a few different join orders. Some of these are very inefficient,
+-- but the planner considers them all viable.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+ LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+ LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+ WHERE val1 = 1 AND val2 = 1;
+ QUERY PLAN
+------------------------------------------
+ Hash Join
+ Hash Cond: (f.dim2_id = d2.id)
+ -> Hash Join
+ Hash Cond: (f.dim1_id = d1.id)
+ -> Seq Scan on jo_fact f
+ -> Hash
+ -> Seq Scan on jo_dim1 d1
+ Filter: (val1 = 1)
+ -> Hash
+ -> Seq Scan on jo_dim2 d2
+ Filter: (val2 = 1)
+ Supplied Plan Advice:
+ JOIN_ORDER(f d1 d2) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(f d1 d2)
+ HASH_JOIN(d1 d2)
+ SEQ_SCAN(f d1 d2)
+ NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+ LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+ LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+ WHERE val1 = 1 AND val2 = 1;
+ QUERY PLAN
+------------------------------------------
+ Hash Join
+ Hash Cond: (f.dim1_id = d1.id)
+ -> Hash Join
+ Hash Cond: (f.dim2_id = d2.id)
+ -> Seq Scan on jo_fact f
+ -> Hash
+ -> Seq Scan on jo_dim2 d2
+ Filter: (val2 = 1)
+ -> Hash
+ -> Seq Scan on jo_dim1 d1
+ Filter: (val1 = 1)
+ Supplied Plan Advice:
+ JOIN_ORDER(f d2 d1) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(f d2 d1)
+ HASH_JOIN(d2 d1)
+ SEQ_SCAN(f d2 d1)
+ NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+ LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+ LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+ WHERE val1 = 1 AND val2 = 1;
+ QUERY PLAN
+-----------------------------------------
+ Hash Join
+ Hash Cond: (f.dim2_id = d2.id)
+ -> Hash Join
+ Hash Cond: (d1.id = f.dim1_id)
+ -> Seq Scan on jo_dim1 d1
+ Filter: (val1 = 1)
+ -> Hash
+ -> Seq Scan on jo_fact f
+ -> Hash
+ -> Seq Scan on jo_dim2 d2
+ Filter: (val2 = 1)
+ Supplied Plan Advice:
+ JOIN_ORDER(d1 f d2) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(d1 f d2)
+ HASH_JOIN(f d2)
+ SEQ_SCAN(d1 f d2)
+ NO_GATHER(f d1 d2)
+(18 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f (d1 d2))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_fact f
+ LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
+ LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
+ WHERE val1 = 1 AND val2 = 1;
+ QUERY PLAN
+------------------------------------------------------------
+ Hash Join
+ Hash Cond: ((f.dim1_id = d1.id) AND (f.dim2_id = d2.id))
+ -> Seq Scan on jo_fact f
+ -> Hash
+ -> Nested Loop
+ -> Seq Scan on jo_dim1 d1
+ Filter: (val1 = 1)
+ -> Materialize
+ -> Seq Scan on jo_dim2 d2
+ Filter: (val2 = 1)
+ Supplied Plan Advice:
+ JOIN_ORDER(f (d1 d2)) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(f (d1 d2))
+ NESTED_LOOP_MATERIALIZE(d2)
+ HASH_JOIN(d2)
+ SEQ_SCAN(f d1 d2)
+ NO_GATHER(f d1 d2)
+(18 rows)
+
+COMMIT;
+-- The unusual formulation of this query is intended to prevent the query
+-- planner from reducing the FULL JOIN to some other join type, so that we
+-- can test what happens with a join type that cannot be reordered.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+ INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+ ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+ QUERY PLAN
+-------------------------------------------------------------
+ Nested Loop
+ Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+ -> Merge Full Join
+ Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+ -> Sort
+ Sort Key: ((d2.id + 0))
+ -> Seq Scan on jo_dim2 d2
+ -> Sort
+ Sort Key: ((f.dim2_id + 0))
+ -> Seq Scan on jo_fact f
+ -> Materialize
+ -> Seq Scan on jo_dim1 d1
+ Generated Plan Advice:
+ JOIN_ORDER(d2 f d1)
+ MERGE_JOIN_PLAIN(f)
+ NESTED_LOOP_MATERIALIZE(d1)
+ SEQ_SCAN(d2 f d1)
+ NO_GATHER(d1 f d2)
+(18 rows)
+
+-- We should not be able to force the planner to join f to d1 first, because
+-- that is not a valid join order, but we should be able to force the planner
+-- to make either d2 or f the driving table.
+BEGIN;
+-- XXX: The advice feedback says 'partially matched' here which isn't exactly
+-- wrong given the way that flag is handled in the code, but it's at the very
+-- least confusing. Something should probably be improved here.
+SET LOCAL pg_plan_advice.advice = 'join_order(f d1 d2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+ INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+ ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+ QUERY PLAN
+-------------------------------------------------------------
+ Nested Loop
+ Disabled: true
+ Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+ -> Merge Full Join
+ Disabled: true
+ Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+ -> Sort
+ Sort Key: ((d2.id + 0))
+ -> Seq Scan on jo_dim2 d2
+ -> Sort
+ Sort Key: ((f.dim2_id + 0))
+ -> Seq Scan on jo_fact f
+ -> Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+ JOIN_ORDER(f d1 d2) /* partially matched */
+ Generated Plan Advice:
+ JOIN_ORDER(d2 f d1)
+ MERGE_JOIN_PLAIN(f)
+ NESTED_LOOP_PLAIN(d1)
+ SEQ_SCAN(d2 f d1)
+ NO_GATHER(d1 f d2)
+(21 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+ INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+ ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+ QUERY PLAN
+-------------------------------------------------------------
+ Nested Loop
+ Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+ -> Merge Full Join
+ Merge Cond: (((f.dim2_id + 0)) = ((d2.id + 0)))
+ -> Sort
+ Sort Key: ((f.dim2_id + 0))
+ -> Seq Scan on jo_fact f
+ -> Sort
+ Sort Key: ((d2.id + 0))
+ -> Seq Scan on jo_dim2 d2
+ -> Materialize
+ -> Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+ JOIN_ORDER(f d2 d1) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(f d2 d1)
+ MERGE_JOIN_PLAIN(d2)
+ NESTED_LOOP_MATERIALIZE(d1)
+ SEQ_SCAN(f d2 d1)
+ NO_GATHER(d1 f d2)
+(20 rows)
+
+SET LOCAL pg_plan_advice.advice = 'join_order(d2 f d1)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM jo_dim1 d1
+ INNER JOIN (jo_fact f FULL JOIN jo_dim2 d2 ON f.dim2_id + 0 = d2.id + 0)
+ ON d1.id = f.dim1_id OR f.dim1_id IS NULL;
+ QUERY PLAN
+-------------------------------------------------------------
+ Nested Loop
+ Join Filter: ((d1.id = f.dim1_id) OR (f.dim1_id IS NULL))
+ -> Merge Full Join
+ Merge Cond: (((d2.id + 0)) = ((f.dim2_id + 0)))
+ -> Sort
+ Sort Key: ((d2.id + 0))
+ -> Seq Scan on jo_dim2 d2
+ -> Sort
+ Sort Key: ((f.dim2_id + 0))
+ -> Seq Scan on jo_fact f
+ -> Materialize
+ -> Seq Scan on jo_dim1 d1
+ Supplied Plan Advice:
+ JOIN_ORDER(d2 f d1) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(d2 f d1)
+ MERGE_JOIN_PLAIN(f)
+ NESTED_LOOP_MATERIALIZE(d1)
+ SEQ_SCAN(d2 f d1)
+ NO_GATHER(d1 f d2)
+(20 rows)
+
+COMMIT;
+-- XXX: add tests for join order prefix matching
+-- XXX: join_order(justonerel) shouldn't report partially matched
--- /dev/null
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE join_dim (id serial primary key, dim text)
+ WITH (autovacuum_enabled = false);
+INSERT INTO join_dim (dim) SELECT random()::text FROM generate_series(1,100) g;
+VACUUM ANALYZE join_dim;
+CREATE TABLE join_fact (
+ id int primary key,
+ dim_id integer not null references join_dim (id)
+) WITH (autovacuum_enabled = false);
+INSERT INTO join_fact
+ SELECT g, (g%3)+1 FROM generate_series(1,100000) g;
+CREATE INDEX join_fact_dim_id ON join_fact (dim_id);
+VACUUM ANALYZE join_fact;
+-- We expect a hash join by default.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+ QUERY PLAN
+------------------------------------
+ Hash Join
+ Hash Cond: (f.dim_id = d.id)
+ -> Seq Scan on join_fact f
+ -> Hash
+ -> Seq Scan on join_dim d
+ Generated Plan Advice:
+ JOIN_ORDER(f d)
+ HASH_JOIN(d)
+ SEQ_SCAN(f d)
+ NO_GATHER(f d)
+(10 rows)
+
+-- Try forcing each join method in turn with join_dim as the inner table.
+-- All of these should work except for MERGE_JOIN_MATERIALIZE; that will
+-- fail, because the planner knows that join_dim (id) is unique, and will
+-- refuse to add mark/restore overhead.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+ QUERY PLAN
+------------------------------------
+ Hash Join
+ Hash Cond: (f.dim_id = d.id)
+ -> Seq Scan on join_fact f
+ -> Hash
+ -> Seq Scan on join_dim d
+ Supplied Plan Advice:
+ HASH_JOIN(d) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(f d)
+ HASH_JOIN(d)
+ SEQ_SCAN(f d)
+ NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+ QUERY PLAN
+----------------------------------------------------------------
+ Merge Join
+ Disabled: true
+ Merge Cond: (f.dim_id = d.id)
+ -> Index Scan using join_fact_dim_id on join_fact f
+ -> Index Scan using join_dim_pkey on join_dim d
+ Supplied Plan Advice:
+ MERGE_JOIN_MATERIALIZE(d) /* matched, failed */
+ Generated Plan Advice:
+ JOIN_ORDER(f d)
+ MERGE_JOIN_PLAIN(d)
+ INDEX_SCAN(f public.join_fact_dim_id d public.join_dim_pkey)
+ NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+ QUERY PLAN
+----------------------------------------------------------------
+ Merge Join
+ Merge Cond: (f.dim_id = d.id)
+ -> Index Scan using join_fact_dim_id on join_fact f
+ -> Index Scan using join_dim_pkey on join_dim d
+ Supplied Plan Advice:
+ MERGE_JOIN_PLAIN(d) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(f d)
+ MERGE_JOIN_PLAIN(d)
+ INDEX_SCAN(f public.join_fact_dim_id d public.join_dim_pkey)
+ NO_GATHER(f d)
+(11 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+ QUERY PLAN
+--------------------------------------------
+ Nested Loop
+ Join Filter: (f.dim_id = d.id)
+ -> Seq Scan on join_fact f
+ -> Materialize
+ -> Seq Scan on join_dim d
+ Supplied Plan Advice:
+ NESTED_LOOP_MATERIALIZE(d) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(f d)
+ NESTED_LOOP_MATERIALIZE(d)
+ SEQ_SCAN(f d)
+ NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+ QUERY PLAN
+----------------------------------------------------------
+ Nested Loop
+ -> Seq Scan on join_fact f
+ -> Memoize
+ Cache Key: f.dim_id
+ Cache Mode: logical
+ -> Index Scan using join_dim_pkey on join_dim d
+ Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+ NESTED_LOOP_MEMOIZE(d) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(f d)
+ NESTED_LOOP_MEMOIZE(d)
+ SEQ_SCAN(f)
+ INDEX_SCAN(d public.join_dim_pkey)
+ NO_GATHER(f d)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(d)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+ QUERY PLAN
+----------------------------------------------------
+ Nested Loop
+ -> Seq Scan on join_fact f
+ -> Index Scan using join_dim_pkey on join_dim d
+ Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+ NESTED_LOOP_PLAIN(d) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(f d)
+ NESTED_LOOP_PLAIN(d)
+ SEQ_SCAN(f)
+ INDEX_SCAN(d public.join_dim_pkey)
+ NO_GATHER(f d)
+(12 rows)
+
+COMMIT;
+-- Now try forcing each join method in turn with join_fact as the inner
+-- table. All of these should work.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'HASH_JOIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+ QUERY PLAN
+-------------------------------------
+ Hash Join
+ Hash Cond: (d.id = f.dim_id)
+ -> Seq Scan on join_dim d
+ -> Hash
+ -> Seq Scan on join_fact f
+ Supplied Plan Advice:
+ HASH_JOIN(f) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(d f)
+ HASH_JOIN(f)
+ SEQ_SCAN(d f)
+ NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+ QUERY PLAN
+----------------------------------------------------------------
+ Merge Join
+ Merge Cond: (d.id = f.dim_id)
+ -> Index Scan using join_dim_pkey on join_dim d
+ -> Materialize
+ -> Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+ MERGE_JOIN_MATERIALIZE(f) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(d f)
+ MERGE_JOIN_MATERIALIZE(f)
+ INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+ NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'MERGE_JOIN_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+ QUERY PLAN
+----------------------------------------------------------------
+ Merge Join
+ Merge Cond: (d.id = f.dim_id)
+ -> Index Scan using join_dim_pkey on join_dim d
+ -> Index Scan using join_fact_dim_id on join_fact f
+ Supplied Plan Advice:
+ MERGE_JOIN_PLAIN(f) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(d f)
+ MERGE_JOIN_PLAIN(f)
+ INDEX_SCAN(d public.join_dim_pkey f public.join_fact_dim_id)
+ NO_GATHER(f d)
+(11 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MATERIALIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+ QUERY PLAN
+--------------------------------------------
+ Nested Loop
+ Join Filter: (f.dim_id = d.id)
+ -> Seq Scan on join_dim d
+ -> Materialize
+ -> Seq Scan on join_fact f
+ Supplied Plan Advice:
+ NESTED_LOOP_MATERIALIZE(f) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(d f)
+ NESTED_LOOP_MATERIALIZE(f)
+ SEQ_SCAN(d f)
+ NO_GATHER(f d)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_MEMOIZE(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+ QUERY PLAN
+--------------------------------------------------------------
+ Nested Loop
+ -> Seq Scan on join_dim d
+ -> Memoize
+ Cache Key: d.id
+ Cache Mode: logical
+ -> Index Scan using join_fact_dim_id on join_fact f
+ Index Cond: (dim_id = d.id)
+ Supplied Plan Advice:
+ NESTED_LOOP_MEMOIZE(f) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(d f)
+ NESTED_LOOP_MEMOIZE(f)
+ SEQ_SCAN(d)
+ INDEX_SCAN(f public.join_fact_dim_id)
+ NO_GATHER(f d)
+(15 rows)
+
+SET LOCAL pg_plan_advice.advice = 'NESTED_LOOP_PLAIN(f)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+ QUERY PLAN
+--------------------------------------------------------
+ Nested Loop
+ -> Seq Scan on join_dim d
+ -> Index Scan using join_fact_dim_id on join_fact f
+ Index Cond: (dim_id = d.id)
+ Supplied Plan Advice:
+ NESTED_LOOP_PLAIN(f) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(d f)
+ NESTED_LOOP_PLAIN(f)
+ SEQ_SCAN(d)
+ INDEX_SCAN(f public.join_fact_dim_id)
+ NO_GATHER(f d)
+(12 rows)
+
+COMMIT;
+-- We can't force a foreign join between these tables, because they
+-- aren't foreign tables.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'FOREIGN_JOIN((f d))';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
+ QUERY PLAN
+----------------------------------------------------
+ Nested Loop
+ Disabled: true
+ -> Seq Scan on join_fact f
+ -> Index Scan using join_dim_pkey on join_dim d
+ Index Cond: (id = f.dim_id)
+ Supplied Plan Advice:
+ FOREIGN_JOIN((f d)) /* matched, failed */
+ Generated Plan Advice:
+ JOIN_ORDER(f d)
+ NESTED_LOOP_PLAIN(d)
+ SEQ_SCAN(f)
+ INDEX_SCAN(d public.join_dim_pkey)
+ NO_GATHER(f d)
+(13 rows)
+
+COMMIT;
--- /dev/null
+CREATE EXTENSION pg_plan_advice;
+SET debug_parallel_query = off;
+-- Try clearing advice before we've collected any.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice
+---------------------------------
+
+(1 row)
+
+-- Set a small advice collection limit so that we'll exceed it.
+SET pg_plan_advice.local_collection_limit = 2;
+-- Set up a dummy table.
+CREATE TABLE dummy_table (a int primary key, b text)
+ WITH (autovacuum_enabled = false, parallel_workers = 0);
+-- Test queries.
+SELECT * FROM dummy_table a, dummy_table b;
+ a | b | a | b
+---+---+---+---
+(0 rows)
+
+SELECT * FROM dummy_table;
+ a | b
+---+---
+(0 rows)
+
+-- Should return the advice from the second test query.
+SELECT advice FROM pg_get_collected_local_advice() ORDER BY id LIMIT 1;
+ advice
+------------------------
+ SEQ_SCAN(dummy_table) +
+ NO_GATHER(dummy_table)
+(1 row)
+
+-- Now try clearing advice again.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice
+---------------------------------
+
+(1 row)
+
+-- Raise the collection limit so that the collector uses multiple chunks.
+SET pg_plan_advice.local_collection_limit = 2000;
+-- Push a bunch of queries through the collector.
+DO $$
+BEGIN
+ FOR x IN 1..2000 LOOP
+ EXECUTE 'SELECT * FROM dummy_table';
+ END LOOP;
+END
+$$;
+-- Check that the collector worked.
+SELECT COUNT(*) FROM pg_get_collected_local_advice();
+ count
+-------
+ 2000
+(1 row)
+
+-- And clear one more time, to verify that this doesn't cause a problem
+-- even with a larger number of entries.
+SELECT pg_clear_collected_local_advice();
+ pg_clear_collected_local_advice
+---------------------------------
+
+(1 row)
+
--- /dev/null
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET enable_partitionwise_join = true;
+CREATE TABLE pt1 (id integer primary key, dim1 text, val1 int)
+ PARTITION BY RANGE (id);
+CREATE TABLE pt1a PARTITION OF pt1 FOR VALUES FROM (1) to (1001)
+ WITH (autovacuum_enabled = false);
+CREATE TABLE pt1b PARTITION OF pt1 FOR VALUES FROM (1001) to (2001)
+ WITH (autovacuum_enabled = false);
+CREATE TABLE pt1c PARTITION OF pt1 FOR VALUES FROM (2001) to (3001)
+ WITH (autovacuum_enabled = false);
+INSERT INTO pt1 (id, dim1, val1)
+ SELECT g, 'some filler text ' || g, (g % 3) + 1
+ FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt1;
+CREATE TABLE pt2 (id integer primary key, dim2 text, val2 int)
+ PARTITION BY RANGE (id);
+CREATE TABLE pt2a PARTITION OF pt2 FOR VALUES FROM (1) to (1001)
+ WITH (autovacuum_enabled = false);
+CREATE TABLE pt2b PARTITION OF pt2 FOR VALUES FROM (1001) to (2001)
+ WITH (autovacuum_enabled = false);
+CREATE TABLE pt2c PARTITION OF pt2 FOR VALUES FROM (2001) to (3001)
+ WITH (autovacuum_enabled = false);
+INSERT INTO pt2 (id, dim2, val2)
+ SELECT g, 'some other text ' || g, (g % 5) + 1
+ FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt2;
+CREATE TABLE pt3 (id integer primary key, dim3 text, val3 int)
+ PARTITION BY RANGE (id);
+CREATE TABLE pt3a PARTITION OF pt3 FOR VALUES FROM (1) to (1001)
+ WITH (autovacuum_enabled = false);
+CREATE TABLE pt3b PARTITION OF pt3 FOR VALUES FROM (1001) to (2001)
+ WITH (autovacuum_enabled = false);
+CREATE TABLE pt3c PARTITION OF pt3 FOR VALUES FROM (2001) to (3001)
+ WITH (autovacuum_enabled = false);
+INSERT INTO pt3 (id, dim3, val3)
+ SELECT g, 'a third random text ' || g, (g % 7) + 1
+ FROM generate_series(1,3000) g;
+VACUUM ANALYZE pt3;
+CREATE TABLE ptmismatch (id integer primary key, dimm text, valm int)
+ PARTITION BY RANGE (id);
+CREATE TABLE ptmismatcha PARTITION OF ptmismatch
+ FOR VALUES FROM (1) to (1501)
+ WITH (autovacuum_enabled = false);
+CREATE TABLE ptmismatchb PARTITION OF ptmismatch
+ FOR VALUES FROM (1501) to (3001)
+ WITH (autovacuum_enabled = false);
+INSERT INTO ptmismatch (id, dimm, valm)
+ SELECT g, 'yet another text ' || g, (g % 2) + 1
+ FROM generate_series(1,3000) g;
+VACUUM ANALYZE ptmismatch;
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+ AND val1 = 1 AND val2 = 1 AND val3 = 1;
+ QUERY PLAN
+-------------------------------------------------------------------------------------
+ Append
+ -> Nested Loop
+ -> Hash Join
+ Hash Cond: (pt2_1.id = pt3_1.id)
+ -> Seq Scan on pt2a pt2_1
+ Filter: (val2 = 1)
+ -> Hash
+ -> Seq Scan on pt3a pt3_1
+ Filter: (val3 = 1)
+ -> Index Scan using pt1a_pkey on pt1a pt1_1
+ Index Cond: (id = pt2_1.id)
+ Filter: (val1 = 1)
+ -> Nested Loop
+ -> Hash Join
+ Hash Cond: (pt2_2.id = pt3_2.id)
+ -> Seq Scan on pt2b pt2_2
+ Filter: (val2 = 1)
+ -> Hash
+ -> Seq Scan on pt3b pt3_2
+ Filter: (val3 = 1)
+ -> Index Scan using pt1b_pkey on pt1b pt1_2
+ Index Cond: (id = pt2_2.id)
+ Filter: (val1 = 1)
+ -> Nested Loop
+ -> Hash Join
+ Hash Cond: (pt2_3.id = pt3_3.id)
+ -> Seq Scan on pt2c pt2_3
+ Filter: (val2 = 1)
+ -> Hash
+ -> Seq Scan on pt3c pt3_3
+ Filter: (val3 = 1)
+ -> Index Scan using pt1c_pkey on pt1c pt1_3
+ Index Cond: (id = pt2_3.id)
+ Filter: (val1 = 1)
+ Generated Plan Advice:
+ JOIN_ORDER(pt2/public.pt2a pt3/public.pt3a pt1/public.pt1a)
+ JOIN_ORDER(pt2/public.pt2b pt3/public.pt3b pt1/public.pt1b)
+ JOIN_ORDER(pt2/public.pt2c pt3/public.pt3c pt1/public.pt1c)
+ NESTED_LOOP_PLAIN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+ HASH_JOIN(pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+ SEQ_SCAN(pt2/public.pt2a pt3/public.pt3a pt2/public.pt2b pt3/public.pt3b
+ pt2/public.pt2c pt3/public.pt3c)
+ INDEX_SCAN(pt1/public.pt1a public.pt1a_pkey pt1/public.pt1b public.pt1b_pkey
+ pt1/public.pt1c public.pt1c_pkey)
+ PARTITIONWISE((pt1 pt2 pt3))
+ NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+ pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(47 rows)
+
+-- Suppress partitionwise join, or do it just partially.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE(pt1 pt2 pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+ AND val1 = 1 AND val2 = 1 AND val3 = 1;
+ QUERY PLAN
+-------------------------------------------------------------------------------------
+ Hash Join
+ Hash Cond: (pt1.id = pt2.id)
+ -> Append
+ -> Seq Scan on pt1a pt1_1
+ Filter: (val1 = 1)
+ -> Seq Scan on pt1b pt1_2
+ Filter: (val1 = 1)
+ -> Seq Scan on pt1c pt1_3
+ Filter: (val1 = 1)
+ -> Hash
+ -> Hash Join
+ Hash Cond: (pt2.id = pt3.id)
+ -> Append
+ -> Seq Scan on pt2a pt2_1
+ Filter: (val2 = 1)
+ -> Seq Scan on pt2b pt2_2
+ Filter: (val2 = 1)
+ -> Seq Scan on pt2c pt2_3
+ Filter: (val2 = 1)
+ -> Hash
+ -> Append
+ -> Seq Scan on pt3a pt3_1
+ Filter: (val3 = 1)
+ -> Seq Scan on pt3b pt3_2
+ Filter: (val3 = 1)
+ -> Seq Scan on pt3c pt3_3
+ Filter: (val3 = 1)
+ Supplied Plan Advice:
+ PARTITIONWISE(pt1) /* matched */
+ PARTITIONWISE(pt2) /* matched */
+ PARTITIONWISE(pt3) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(pt1 (pt2 pt3))
+ HASH_JOIN(pt3 pt3)
+ SEQ_SCAN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+ pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b
+ pt3/public.pt3c)
+ PARTITIONWISE(pt1 pt2 pt3)
+ NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+ pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(40 rows)
+
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 pt2) pt3)';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, pt2, pt3 WHERE pt1.id = pt2.id AND pt2.id = pt3.id
+ AND val1 = 1 AND val2 = 1 AND val3 = 1;
+ QUERY PLAN
+-------------------------------------------------------------------------------------
+ Hash Join
+ Hash Cond: (pt1.id = pt3.id)
+ -> Append
+ -> Hash Join
+ Hash Cond: (pt1_1.id = pt2_1.id)
+ -> Seq Scan on pt1a pt1_1
+ Filter: (val1 = 1)
+ -> Hash
+ -> Seq Scan on pt2a pt2_1
+ Filter: (val2 = 1)
+ -> Hash Join
+ Hash Cond: (pt1_2.id = pt2_2.id)
+ -> Seq Scan on pt1b pt1_2
+ Filter: (val1 = 1)
+ -> Hash
+ -> Seq Scan on pt2b pt2_2
+ Filter: (val2 = 1)
+ -> Hash Join
+ Hash Cond: (pt1_3.id = pt2_3.id)
+ -> Seq Scan on pt1c pt1_3
+ Filter: (val1 = 1)
+ -> Hash
+ -> Seq Scan on pt2c pt2_3
+ Filter: (val2 = 1)
+ -> Hash
+ -> Append
+ -> Seq Scan on pt3a pt3_1
+ Filter: (val3 = 1)
+ -> Seq Scan on pt3b pt3_2
+ Filter: (val3 = 1)
+ -> Seq Scan on pt3c pt3_3
+ Filter: (val3 = 1)
+ Supplied Plan Advice:
+ PARTITIONWISE((pt1 pt2)) /* matched */
+ PARTITIONWISE(pt3) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(pt1/public.pt1a pt2/public.pt2a)
+ JOIN_ORDER(pt1/public.pt1b pt2/public.pt2b)
+ JOIN_ORDER(pt1/public.pt1c pt2/public.pt2c)
+ JOIN_ORDER({pt1 pt2} pt3)
+ HASH_JOIN(pt2/public.pt2a pt2/public.pt2b pt2/public.pt2c pt3)
+ SEQ_SCAN(pt1/public.pt1a pt2/public.pt2a pt1/public.pt1b pt2/public.pt2b
+ pt1/public.pt1c pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b
+ pt3/public.pt3c)
+ PARTITIONWISE((pt1 pt2) pt3)
+ NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c pt2/public.pt2a
+ pt2/public.pt2b pt2/public.pt2c pt3/public.pt3a pt3/public.pt3b pt3/public.pt3c)
+(47 rows)
+
+COMMIT;
+-- Can't force a partitionwise join with a mismatched table.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'PARTITIONWISE((pt1 ptmismatch))';
+EXPLAIN (PLAN_ADVICE, COSTS OFF)
+SELECT * FROM pt1, ptmismatch WHERE pt1.id = ptmismatch.id;
+ QUERY PLAN
+---------------------------------------------------------------------------
+ Nested Loop
+ Disabled: true
+ -> Append
+ -> Seq Scan on pt1a pt1_1
+ -> Seq Scan on pt1b pt1_2
+ -> Seq Scan on pt1c pt1_3
+ -> Append
+ -> Index Scan using ptmismatcha_pkey on ptmismatcha ptmismatch_1
+ Index Cond: (id = pt1.id)
+ -> Index Scan using ptmismatchb_pkey on ptmismatchb ptmismatch_2
+ Index Cond: (id = pt1.id)
+ Supplied Plan Advice:
+ PARTITIONWISE((pt1 ptmismatch)) /* matched, failed */
+ Generated Plan Advice:
+ JOIN_ORDER(pt1 ptmismatch)
+ NESTED_LOOP_PLAIN(ptmismatch)
+ SEQ_SCAN(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c)
+ INDEX_SCAN(ptmismatch/public.ptmismatcha public.ptmismatcha_pkey
+ ptmismatch/public.ptmismatchb public.ptmismatchb_pkey)
+ PARTITIONWISE(pt1 ptmismatch)
+ NO_GATHER(pt1/public.pt1a pt1/public.pt1b pt1/public.pt1c
+ ptmismatch/public.ptmismatcha ptmismatch/public.ptmismatchb)
+(22 rows)
+
+COMMIT;
--- /dev/null
+LOAD 'pg_plan_advice';
+SET max_parallel_workers_per_gather = 0;
+SET seq_page_cost = 0.1;
+SET random_page_cost = 0.1;
+SET cpu_tuple_cost = 0;
+SET cpu_index_tuple_cost = 0;
+CREATE TABLE scan_table (a int primary key, b text)
+ WITH (autovacuum_enabled = false);
+INSERT INTO scan_table
+ SELECT g, 'some text ' || g FROM generate_series(1, 100000) g;
+CREATE INDEX scan_table_b ON scan_table USING brin (b);
+VACUUM ANALYZE scan_table;
+-- Sequential scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+ QUERY PLAN
+-------------------------
+ Seq Scan on scan_table
+ Generated Plan Advice:
+ SEQ_SCAN(scan_table)
+ NO_GATHER(scan_table)
+(4 rows)
+
+-- Index scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+ QUERY PLAN
+-------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+ Index Cond: (a = 1)
+ Generated Plan Advice:
+ INDEX_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(5 rows)
+
+-- Index-only scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+ QUERY PLAN
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+ Index Cond: (a = 1)
+ Generated Plan Advice:
+ INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(5 rows)
+
+-- Bitmap heap scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+ WHERE b > 'some text 8';
+ QUERY PLAN
+----------------------------------------------------
+ Bitmap Heap Scan on scan_table
+ Recheck Cond: (b > 'some text 8'::text)
+ -> Bitmap Index Scan on scan_table_b
+ Index Cond: (b > 'some text 8'::text)
+ Generated Plan Advice:
+ BITMAP_HEAP_SCAN(scan_table public.scan_table_b)
+ NO_GATHER(scan_table)
+(7 rows)
+
+-- TID scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+ QUERY PLAN
+-----------------------------------
+ Tid Scan on scan_table
+ TID Cond: (ctid = '(0,1)'::tid)
+ Generated Plan Advice:
+ TID_SCAN(scan_table)
+ NO_GATHER(scan_table)
+(5 rows)
+
+-- TID range scan
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+ WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+ QUERY PLAN
+---------------------------------------------------------------
+ Tid Range Scan on scan_table
+ TID Cond: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Generated Plan Advice:
+ TID_SCAN(scan_table)
+ NO_GATHER(scan_table)
+(5 rows)
+
+-- Try forcing each of our test queries to use the scan type they
+-- wanted to use anyway. This should succeed.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+ QUERY PLAN
+--------------------------------------
+ Seq Scan on scan_table
+ Supplied Plan Advice:
+ SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+ SEQ_SCAN(scan_table)
+ NO_GATHER(scan_table)
+(6 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+ QUERY PLAN
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+ INDEX_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+ QUERY PLAN
+-------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+ INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+ WHERE b > 'some text 8';
+ QUERY PLAN
+-----------------------------------------------------------
+ Bitmap Heap Scan on scan_table
+ Recheck Cond: (b > 'some text 8'::text)
+ -> Bitmap Index Scan on scan_table_b
+ Index Cond: (b > 'some text 8'::text)
+ Supplied Plan Advice:
+ BITMAP_HEAP_SCAN(scan_table scan_table_b) /* matched */
+ Generated Plan Advice:
+ BITMAP_HEAP_SCAN(scan_table public.scan_table_b)
+ NO_GATHER(scan_table)
+(9 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+ QUERY PLAN
+--------------------------------------
+ Tid Scan on scan_table
+ TID Cond: (ctid = '(0,1)'::tid)
+ Supplied Plan Advice:
+ TID_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+ TID_SCAN(scan_table)
+ NO_GATHER(scan_table)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+ WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+ QUERY PLAN
+---------------------------------------------------------------
+ Tid Range Scan on scan_table
+ TID Cond: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Supplied Plan Advice:
+ TID_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+ TID_SCAN(scan_table)
+ NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Try to force a full scan of the table to use some other scan type. All
+-- of these will fail. An index scan or bitmap heap scan could potentially
+-- generate the correct answer, but the planner does not even consider these
+-- possibilities due to the lack of a WHERE clause.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+ QUERY PLAN
+----------------------------------------------------------------
+ Seq Scan on scan_table
+ Disabled: true
+ Supplied Plan Advice:
+ INDEX_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+ SEQ_SCAN(scan_table)
+ NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+ QUERY PLAN
+---------------------------------------------------------------------
+ Seq Scan on scan_table
+ Disabled: true
+ Supplied Plan Advice:
+ INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+ SEQ_SCAN(scan_table)
+ NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+ QUERY PLAN
+----------------------------------------------------------------------
+ Seq Scan on scan_table
+ Disabled: true
+ Supplied Plan Advice:
+ BITMAP_HEAP_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+ SEQ_SCAN(scan_table)
+ NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table;
+ QUERY PLAN
+----------------------------------------------
+ Seq Scan on scan_table
+ Disabled: true
+ Supplied Plan Advice:
+ TID_SCAN(scan_table) /* matched, failed */
+ Generated Plan Advice:
+ SEQ_SCAN(scan_table)
+ NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Try again to force index use. This should now succeed for the INDEX_SCAN
+-- and BITMAP_HEAP_SCAN, but the INDEX_ONLY_SCAN can't be forced because the
+-- query fetches columns not included in the index.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+ QUERY PLAN
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+ Index Cond: (a > 0)
+ Supplied Plan Advice:
+ INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+ INDEX_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+ QUERY PLAN
+---------------------------------------------------------------------
+ Seq Scan on scan_table
+ Disabled: true
+ Filter: (a > 0)
+ Supplied Plan Advice:
+ INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+ SEQ_SCAN(scan_table)
+ NO_GATHER(scan_table)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'BITMAP_HEAP_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a > 0;
+ QUERY PLAN
+--------------------------------------------------------------
+ Bitmap Heap Scan on scan_table
+ Recheck Cond: (a > 0)
+ -> Bitmap Index Scan on scan_table_pkey
+ Index Cond: (a > 0)
+ Supplied Plan Advice:
+ BITMAP_HEAP_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+ BITMAP_HEAP_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(9 rows)
+
+COMMIT;
+-- We can force a primary key lookup to use a sequential scan, but we
+-- can't force it to use an index-only scan (due to the column list)
+-- or a TID scan (due to the absence of a TID qual).
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+ QUERY PLAN
+--------------------------------------
+ Seq Scan on scan_table
+ Filter: (a = 1)
+ Supplied Plan Advice:
+ SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+ SEQ_SCAN(scan_table)
+ NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+ QUERY PLAN
+---------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+ Disabled: true
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ INDEX_ONLY_SCAN(scan_table scan_table_pkey) /* matched, failed */
+ Generated Plan Advice:
+ INDEX_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(8 rows)
+
+SET LOCAL pg_plan_advice.advice = 'TID_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+ QUERY PLAN
+-------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+ Disabled: true
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ TID_SCAN(scan_table) /* matched, failed */
+ Generated Plan Advice:
+ INDEX_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can forcibly downgrade an index-only scan to an index scan, but we can't
+-- force the use of an index that the planner thinks is inapplicable.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+ QUERY PLAN
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+ INDEX_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+ QUERY PLAN
+---------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ INDEX_SCAN(scan_table public.scan_table_pkey) /* matched */
+ Generated Plan Advice:
+ INDEX_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+ QUERY PLAN
+-------------------------------------------------------------
+ Seq Scan on scan_table
+ Disabled: true
+ Filter: (a = 1)
+ Supplied Plan Advice:
+ INDEX_SCAN(scan_table scan_table_b) /* matched, failed */
+ Generated Plan Advice:
+ SEQ_SCAN(scan_table)
+ NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can force the use of a sequential scan in place of a bitmap heap scan,
+-- but a plain index scan on a BRIN index is not possible.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+ WHERE b > 'some text 8';
+ QUERY PLAN
+--------------------------------------
+ Seq Scan on scan_table
+ Filter: (b > 'some text 8'::text)
+ Supplied Plan Advice:
+ SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+ SEQ_SCAN(scan_table)
+ NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_b)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+ QUERY PLAN
+-------------------------------------------------------------
+ Seq Scan on scan_table
+ Disabled: true
+ Filter: (a = 1)
+ Supplied Plan Advice:
+ INDEX_SCAN(scan_table scan_table_b) /* matched, failed */
+ Generated Plan Advice:
+ SEQ_SCAN(scan_table)
+ NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- We can force the use of a sequential scan rather than a TID scan or
+-- TID range scan.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(scan_table)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE ctid = '(0,1)';
+ QUERY PLAN
+--------------------------------------
+ Seq Scan on scan_table
+ Filter: (ctid = '(0,1)'::tid)
+ Supplied Plan Advice:
+ SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+ SEQ_SCAN(scan_table)
+ NO_GATHER(scan_table)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table
+ WHERE ctid > '(1,1)' AND ctid < '(2,1)';
+ QUERY PLAN
+-------------------------------------------------------------
+ Seq Scan on scan_table
+ Filter: ((ctid > '(1,1)'::tid) AND (ctid < '(2,1)'::tid))
+ Supplied Plan Advice:
+ SEQ_SCAN(scan_table) /* matched */
+ Generated Plan Advice:
+ SEQ_SCAN(scan_table)
+ NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Test more complex scenarios with index scans.
+BEGIN;
+-- Should still work if we mention the schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+ QUERY PLAN
+---------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ INDEX_SCAN(scan_table public.scan_table_pkey) /* matched */
+ Generated Plan Advice:
+ INDEX_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(7 rows)
+
+-- But not if we mention the wrong schema.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table cilbup.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+ QUERY PLAN
+-----------------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ INDEX_SCAN(scan_table cilbup.scan_table_pkey) /* matched, inapplicable */
+ Generated Plan Advice:
+ INDEX_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(7 rows)
+
+-- It's OK to repeat the same advice.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+ QUERY PLAN
+--------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ INDEX_SCAN(scan_table scan_table_pkey) /* matched */
+ Generated Plan Advice:
+ INDEX_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(8 rows)
+
+-- But it doesn't work if the index target is even notionally different.
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table scan_table_pkey scan_table public.scan_table_pkey)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT * FROM scan_table WHERE a = 1;
+ QUERY PLAN
+----------------------------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ INDEX_SCAN(scan_table scan_table_pkey) /* matched, conflicting */
+ INDEX_SCAN(scan_table public.scan_table_pkey) /* matched, conflicting */
+ Generated Plan Advice:
+ INDEX_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(8 rows)
+
+COMMIT;
+-- Test assorted incorrect advice.
+BEGIN;
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(nothing)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+ QUERY PLAN
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ SEQ_SCAN(nothing) /* not matched */
+ Generated Plan Advice:
+ INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+ QUERY PLAN
+------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ INDEX_SCAN(nothing whatsoever) /* not matched */
+ Generated Plan Advice:
+ INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+ QUERY PLAN
+--------------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ INDEX_SCAN(scan_table bogus) /* matched, inapplicable, failed */
+ Generated Plan Advice:
+ INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(nothing whatsoever)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+ QUERY PLAN
+---------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ INDEX_ONLY_SCAN(nothing whatsoever) /* not matched */
+ Generated Plan Advice:
+ INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(7 rows)
+
+SET LOCAL pg_plan_advice.advice = 'INDEX_ONLY_SCAN(scan_table bogus)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE) SELECT a FROM scan_table WHERE a = 1;
+ QUERY PLAN
+-----------------------------------------------------------------
+ Index Only Scan using scan_table_pkey on scan_table
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ INDEX_ONLY_SCAN(scan_table bogus) /* matched, inapplicable */
+ Generated Plan Advice:
+ INDEX_ONLY_SCAN(scan_table public.scan_table_pkey)
+ NO_GATHER(scan_table)
+(7 rows)
+
+COMMIT;
+-- Test our ability to refer to multiple instances of the same alias.
+BEGIN;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+ LEFT JOIN scan_table s ON g = s.a;
+ QUERY PLAN
+-------------------------------------------------------------------
+ Nested Loop Left Join
+ -> Nested Loop Left Join
+ -> Function Scan on generate_series g
+ -> Index Scan using scan_table_pkey on scan_table s
+ Index Cond: (a = g.g)
+ -> Index Scan using scan_table_pkey on scan_table s_1
+ Index Cond: (a = g.g)
+ Generated Plan Advice:
+ JOIN_ORDER(g s s#2)
+ NESTED_LOOP_PLAIN(s s#2)
+ INDEX_SCAN(s public.scan_table_pkey s#2 public.scan_table_pkey)
+ NO_GATHER(g s s#2)
+(12 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+ LEFT JOIN scan_table s ON g = s.a;
+ QUERY PLAN
+----------------------------------------------------------
+ Nested Loop Left Join
+ -> Hash Left Join
+ Hash Cond: (g.g = s.a)
+ -> Function Scan on generate_series g
+ -> Hash
+ -> Seq Scan on scan_table s
+ -> Index Scan using scan_table_pkey on scan_table s_1
+ Index Cond: (a = g.g)
+ Supplied Plan Advice:
+ SEQ_SCAN(s) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(g s s#2)
+ NESTED_LOOP_PLAIN(s#2)
+ HASH_JOIN(s)
+ SEQ_SCAN(s)
+ INDEX_SCAN(s#2 public.scan_table_pkey)
+ NO_GATHER(g s s#2)
+(17 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+ LEFT JOIN scan_table s ON g = s.a;
+ QUERY PLAN
+--------------------------------------------------------------
+ Hash Left Join
+ Hash Cond: (g.g = s_1.a)
+ -> Nested Loop Left Join
+ -> Function Scan on generate_series g
+ -> Index Scan using scan_table_pkey on scan_table s
+ Index Cond: (a = g.g)
+ -> Hash
+ -> Seq Scan on scan_table s_1
+ Supplied Plan Advice:
+ SEQ_SCAN(s#2) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(g s s#2)
+ NESTED_LOOP_PLAIN(s)
+ HASH_JOIN(s#2)
+ SEQ_SCAN(s#2)
+ INDEX_SCAN(s public.scan_table_pkey)
+ NO_GATHER(g s s#2)
+(17 rows)
+
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s) SEQ_SCAN(s#2)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (generate_series(1,10) g LEFT JOIN scan_table s ON g = s.a) x
+ LEFT JOIN scan_table s ON g = s.a;
+ QUERY PLAN
+------------------------------------------------
+ Hash Left Join
+ Hash Cond: (g.g = s_1.a)
+ -> Hash Left Join
+ Hash Cond: (g.g = s.a)
+ -> Function Scan on generate_series g
+ -> Hash
+ -> Seq Scan on scan_table s
+ -> Hash
+ -> Seq Scan on scan_table s_1
+ Supplied Plan Advice:
+ SEQ_SCAN(s) /* matched */
+ SEQ_SCAN(s#2) /* matched */
+ Generated Plan Advice:
+ JOIN_ORDER(g s s#2)
+ HASH_JOIN(s s#2)
+ SEQ_SCAN(s s#2)
+ NO_GATHER(g s s#2)
+(17 rows)
+
+COMMIT;
+-- Test our ability to refer to scans within a subquery.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+ QUERY PLAN
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+ Index Cond: (a = 1)
+ Generated Plan Advice:
+ INDEX_SCAN(s@x public.scan_table_pkey)
+ NO_GATHER(x s@x)
+(5 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+ QUERY PLAN
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+ Index Cond: (a = 1)
+ Generated Plan Advice:
+ INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+ NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(5 rows)
+
+BEGIN;
+-- Should not match.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+ QUERY PLAN
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ SEQ_SCAN(s) /* not matched */
+ Generated Plan Advice:
+ INDEX_SCAN(s@x public.scan_table_pkey)
+ NO_GATHER(x s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+ QUERY PLAN
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ SEQ_SCAN(s) /* not matched */
+ Generated Plan Advice:
+ INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+ NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(7 rows)
+
+-- Should match first query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@x)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+ QUERY PLAN
+-------------------------------
+ Seq Scan on scan_table s
+ Filter: (a = 1)
+ Supplied Plan Advice:
+ SEQ_SCAN(s@x) /* matched */
+ Generated Plan Advice:
+ SEQ_SCAN(s@x)
+ NO_GATHER(x s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+ QUERY PLAN
+---------------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ SEQ_SCAN(s@x) /* not matched */
+ Generated Plan Advice:
+ INDEX_SCAN(s@unnamed_subquery public.scan_table_pkey)
+ NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(7 rows)
+
+-- Should match second query only.
+SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(s@unnamed_subquery)';
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0) x;
+ QUERY PLAN
+--------------------------------------------------
+ Index Scan using scan_table_pkey on scan_table s
+ Index Cond: (a = 1)
+ Supplied Plan Advice:
+ SEQ_SCAN(s@unnamed_subquery) /* not matched */
+ Generated Plan Advice:
+ INDEX_SCAN(s@x public.scan_table_pkey)
+ NO_GATHER(x s@x)
+(7 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+SELECT * FROM (SELECT * FROM scan_table s WHERE a = 1 OFFSET 0);
+ QUERY PLAN
+--------------------------------------------------
+ Seq Scan on scan_table s
+ Filter: (a = 1)
+ Supplied Plan Advice:
+ SEQ_SCAN(s@unnamed_subquery) /* matched */
+ Generated Plan Advice:
+ SEQ_SCAN(s@unnamed_subquery)
+ NO_GATHER(unnamed_subquery s@unnamed_subquery)
+(7 rows)
+
+COMMIT;
--- /dev/null
+LOAD 'pg_plan_advice';
+-- An empty string is allowed, and so is an empty target list.
+SET pg_plan_advice.advice = '';
+SET pg_plan_advice.advice = 'SEQ_SCAN()';
+-- Test assorted variations in capitalization, whitespace, and which parts of
+-- the relation identifier are included. These should all work.
+SET pg_plan_advice.advice = 'SEQ_SCAN(x)';
+SET pg_plan_advice.advice = 'seq_scan(x@y)';
+SET pg_plan_advice.advice = 'SEQ_scan(x#2)';
+SET pg_plan_advice.advice = 'SEQ_SCAN (x/y)';
+SET pg_plan_advice.advice = ' SEQ_SCAN ( x / y . z ) ';
+SET pg_plan_advice.advice = 'SEQ_SCAN("x"#2/"y"."z"@"t")';
+-- Syntax errors.
+SET pg_plan_advice.advice = 'SEQUENTIAL_SCAN(x)';
+ERROR: invalid value for parameter "pg_plan_advice.advice": "SEQUENTIAL_SCAN(x)"
+DETAIL: Could not parse advice: syntax error at or near "SEQUENTIAL_SCAN"
+SET pg_plan_advice.advice = 'SEQ_SCAN';
+ERROR: invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN"
+DETAIL: Could not parse advice: syntax error at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN(';
+ERROR: invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN("
+DETAIL: Could not parse advice: syntax error at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN("';
+ERROR: invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN(""
+DETAIL: Could not parse advice: unterminated quoted identifier at end of input
+SET pg_plan_advice.advice = 'SEQ_SCAN(#';
+ERROR: invalid value for parameter "pg_plan_advice.advice": "SEQ_SCAN(#"
+DETAIL: Could not parse advice: syntax error at or near "#"
+SET pg_plan_advice.advice = '()';
+ERROR: invalid value for parameter "pg_plan_advice.advice": "()"
+DETAIL: Could not parse advice: syntax error at or near "("
+SET pg_plan_advice.advice = '123';
+ERROR: invalid value for parameter "pg_plan_advice.advice": "123"
+DETAIL: Could not parse advice: syntax error at or near "123"
+-- Legal comments.
+SET pg_plan_advice.advice = '/**/';
+SET pg_plan_advice.advice = 'HASH_JOIN(_)/***/';
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(/*x*/y)';
+SET pg_plan_advice.advice = '/* comment */ HASH_JOIN(y//*x*/z)';
+-- Unterminated comments.
+SET pg_plan_advice.advice = '/*';
+ERROR: invalid value for parameter "pg_plan_advice.advice": "/*"
+DETAIL: Could not parse advice: unterminated comment at end of input
+SET pg_plan_advice.advice = 'JOIN_ORDER("fOO") /* oops';
+ERROR: invalid value for parameter "pg_plan_advice.advice": "JOIN_ORDER("fOO") /* oops"
+DETAIL: Could not parse advice: unterminated comment at end of input
+-- Nested comments are not supported, so the first of these is legal and
+-- the second is not.
+SET pg_plan_advice.advice = '/*/*/';
+SET pg_plan_advice.advice = '/*/* stuff */*/';
+ERROR: invalid value for parameter "pg_plan_advice.advice": "/*/* stuff */*/"
+DETAIL: Could not parse advice: syntax error at or near "*"
+-- Foreign join requires multiple relation identifiers.
+SET pg_plan_advice.advice = 'FOREIGN_JOIN(a)';
+ERROR: invalid value for parameter "pg_plan_advice.advice": "FOREIGN_JOIN(a)"
+DETAIL: Could not parse advice: FOREIGN_JOIN targets must contain more than one relation identifier at or near ")"
+SET pg_plan_advice.advice = 'FOREIGN_JOIN((a))';
+ERROR: invalid value for parameter "pg_plan_advice.advice": "FOREIGN_JOIN((a))"
+DETAIL: Could not parse advice: FOREIGN_JOIN targets must contain more than one relation identifier at or near ")"
--- /dev/null
+# Copyright (c) 2022-2024, PostgreSQL Global Development Group
+
+pg_plan_advice_sources = files(
+ 'pg_plan_advice.c',
+ 'pgpa_ast.c',
+ 'pgpa_collector.c',
+ 'pgpa_identifier.c',
+ 'pgpa_join.c',
+ 'pgpa_output.c',
+ 'pgpa_planner.c',
+ 'pgpa_scan.c',
+ 'pgpa_trove.c',
+ 'pgpa_walker.c',
+)
+
+pgpa_scanner = custom_target('pgpa_scanner',
+ input: 'pgpa_scanner.l',
+ output: 'pgpa_scanner.c',
+ command: flex_cmd,
+)
+generated_sources += pgpa_scanner
+pg_plan_advice_sources += pgpa_scanner
+
+pgpa_parser = custom_target('pgpa_parser',
+ input: 'pgpa_parser.y',
+ kwargs: bison_kw,
+)
+generated_sources += pgpa_parser.to_list()
+pg_plan_advice_sources += pgpa_parser
+
+if host_system == 'windows'
+ pg_plan_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+ '--NAME', 'pg_plan_advice',
+ '--FILEDESC', 'pg_plan_advice - help the planner get the right plan',])
+endif
+
+pg_plan_advice = shared_module('pg_plan_advice',
+ pg_plan_advice_sources,
+ include_directories: include_directories('.'),
+ kwargs: contrib_mod_args,
+)
+contrib_targets += pg_plan_advice
+
+install_data(
+ 'pg_plan_advice--1.0.sql',
+ 'pg_plan_advice.control',
+ kwargs: contrib_data_args,
+)
+
+tests += {
+ 'name': 'pg_plan_advice',
+ 'sd': meson.current_source_dir(),
+ 'bd': meson.current_build_dir(),
+ 'regress': {
+ 'sql': [
+ 'gather',
+ 'join_order',
+ 'join_strategy',
+ 'local_collector',
+ 'partitionwise',
+ 'scan',
+ 'syntax',
+ ],
+ },
+ 'tap': {
+ 'tests': [
+ 't/001_regress.pl',
+ ],
+ },
+}
--- /dev/null
+/* contrib/pg_plan_advice/pg_plan_advice--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION pg_plan_advice" to load this file. \quit
+
+CREATE FUNCTION pg_clear_collected_local_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_clear_collected_shared_advice()
+RETURNS void
+AS 'MODULE_PATHNAME', 'pg_clear_collected_shared_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_local_advice(
+ OUT id bigint,
+ OUT userid oid,
+ OUT dbid oid,
+ OUT queryid bigint,
+ OUT collection_time timestamptz,
+ OUT query text,
+ OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_local_advice'
+LANGUAGE C STRICT;
+
+CREATE FUNCTION pg_get_collected_shared_advice(
+ OUT id bigint,
+ OUT userid oid,
+ OUT dbid oid,
+ OUT queryid bigint,
+ OUT collection_time timestamptz,
+ OUT query text,
+ OUT advice text
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_get_collected_shared_advice'
+LANGUAGE C STRICT;
+
+REVOKE ALL ON FUNCTION pg_get_collected_shared_advice() FROM PUBLIC;
--- /dev/null
+/*-------------------------------------------------------------------------
+ *
+ * pg_plan_advice.c
+ * main entrypoints for generating and applying planner advice
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pg_plan_advice.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_ast.h"
+#include "pgpa_collector.h"
+#include "pgpa_identifier.h"
+#include "pgpa_output.h"
+#include "pgpa_planner.h"
+#include "pgpa_trove.h"
+#include "pgpa_walker.h"
+
+#include "commands/defrem.h"
+#include "commands/explain.h"
+#include "commands/explain_format.h"
+#include "commands/explain_state.h"
+#include "funcapi.h"
+#include "optimizer/planner.h"
+#include "storage/dsm_registry.h"
+#include "utils/guc.h"
+
+PG_MODULE_MAGIC;
+
+static pgpa_shared_state *pgpa_state = NULL;
+static dsa_area *pgpa_dsa_area = NULL;
+
+/* GUC variables */
+char *pg_plan_advice_advice = NULL;
+static bool pg_plan_advice_always_explain_supplied_advice = true;
+int pg_plan_advice_local_collection_limit = 0;
+int pg_plan_advice_shared_collection_limit = 0;
+
+/* Saved hook value */
+static explain_per_plan_hook_type prev_explain_per_plan = NULL;
+
+/* Other file-level globals */
+static int es_extension_id;
+static MemoryContext pgpa_memory_context = NULL;
+
+static void pg_plan_advice_explain_option_handler(ExplainState *es,
+ DefElem *opt,
+ ParseState *pstate);
+static void pg_plan_advice_explain_per_plan_hook(PlannedStmt *plannedstmt,
+ IntoClause *into,
+ ExplainState *es,
+ const char *queryString,
+ ParamListInfo params,
+ QueryEnvironment *queryEnv);
+static bool pg_plan_advice_advice_check_hook(char **newval, void **extra,
+ GucSource source);
+static DefElem *find_defelem_by_defname(List *deflist, char *defname);
+
+/*
+ * Initialize this module.
+ */
+void
+_PG_init(void)
+{
+ DefineCustomStringVariable("pg_plan_advice.advice",
+ "advice to apply during query planning",
+ NULL,
+ &pg_plan_advice_advice,
+ NULL,
+ PGC_USERSET,
+ 0,
+ pg_plan_advice_advice_check_hook,
+ NULL,
+ NULL);
+
+ DefineCustomBoolVariable("pg_plan_advice.always_explain_supplied_advice",
+ "EXPLAIN output includes supplied advice even without EXPLAIN (PLAN_ADVICE)",
+ NULL,
+ &pg_plan_advice_always_explain_supplied_advice,
+ true,
+ PGC_USERSET,
+ 0,
+ NULL,
+ NULL,
+ NULL);
+
+ DefineCustomIntVariable("pg_plan_advice.local_collection_limit",
+ "# of advice entries to retain in per-backend memory",
+ NULL,
+ &pg_plan_advice_local_collection_limit,
+ 0,
+ 0, INT_MAX,
+ PGC_USERSET,
+ 0,
+ NULL,
+ NULL,
+ NULL);
+
+ DefineCustomIntVariable("pg_plan_advice.shared_collection_limit",
+ "# of advice entries to retain in shared memory",
+ NULL,
+ &pg_plan_advice_shared_collection_limit,
+ 0,
+ 0, INT_MAX,
+ PGC_SUSET,
+ 0,
+ NULL,
+ NULL,
+ NULL);
+
+ MarkGUCPrefixReserved("pg_plan_advice");
+
+ /* Get an ID that we can use to cache data in an ExplainState. */
+ es_extension_id = GetExplainExtensionId("pg_plan_advice");
+
+ /* Register the new EXPLAIN options implemented by this module. */
+ RegisterExtensionExplainOption("plan_advice",
+ pg_plan_advice_explain_option_handler);
+
+ /* Install hooks */
+ pgpa_planner_install_hooks();
+ prev_explain_per_plan = explain_per_plan_hook;
+ explain_per_plan_hook = pg_plan_advice_explain_per_plan_hook;
+}
+
+/*
+ * Initialize shared state when first created.
+ */
+static void
+pgpa_init_shared_state(void *ptr)
+{
+ pgpa_shared_state *state = (pgpa_shared_state *) ptr;
+
+ LWLockInitialize(&state->lock, LWLockNewTrancheId("pg_plan_advice_lock"));
+ state->dsa_tranche = LWLockNewTrancheId("pg_plan_advice_dsa");
+ state->area = DSA_HANDLE_INVALID;
+ state->shared_collector = InvalidDsaPointer;
+}
+
+/*
+ * Return a pointer to a memory context where long-lived data managed by this
+ * module can be stored.
+ */
+MemoryContext
+pg_plan_advice_get_mcxt(void)
+{
+ if (pgpa_memory_context == NULL)
+ pgpa_memory_context = AllocSetContextCreate(TopMemoryContext,
+ "pg_plan_advice",
+ ALLOCSET_DEFAULT_SIZES);
+
+ return pgpa_memory_context;
+}
+
+/*
+ * Get a pointer to our shared state.
+ *
+ * If no shared state exists, create and initialize it. If it does exist but
+ * this backend has not yet accessed it, attach to it. Otherwise, just return
+ * our cached pointer.
+ *
+ * Along the way, make sure the relevant LWLock tranches are registered.
+ */
+pgpa_shared_state *
+pg_plan_advice_attach(void)
+{
+ if (pgpa_state == NULL)
+ {
+ bool found;
+
+ pgpa_state =
+ GetNamedDSMSegment("pg_plan_advice", sizeof(pgpa_shared_state),
+ pgpa_init_shared_state, &found);
+ }
+
+ return pgpa_state;
+}
+
+/*
+ * Return a pointer to pg_plan_advice's DSA area, creating it if needed.
+ */
+dsa_area *
+pg_plan_advice_dsa_area(void)
+{
+ if (pgpa_dsa_area == NULL)
+ {
+ pgpa_shared_state *state = pg_plan_advice_attach();
+ dsa_handle area_handle;
+ MemoryContext oldcontext;
+
+ oldcontext = MemoryContextSwitchTo(pg_plan_advice_get_mcxt());
+
+ LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+ area_handle = state->area;
+ if (area_handle == DSA_HANDLE_INVALID)
+ {
+ pgpa_dsa_area = dsa_create(state->dsa_tranche);
+ dsa_pin(pgpa_dsa_area);
+ state->area = dsa_get_handle(pgpa_dsa_area);
+ LWLockRelease(&state->lock);
+ }
+ else
+ {
+ LWLockRelease(&state->lock);
+ pgpa_dsa_area = dsa_attach(area_handle);
+ }
+
+ dsa_pin_mapping(pgpa_dsa_area);
+
+ MemoryContextSwitchTo(oldcontext);
+ }
+
+ return pgpa_dsa_area;
+}
+
+/*
+ * Handler for EXPLAIN (PLAN_ADVICE).
+ */
+static void
+pg_plan_advice_explain_option_handler(ExplainState *es, DefElem *opt,
+ ParseState *pstate)
+{
+ bool *plan_advice;
+
+ plan_advice = GetExplainExtensionState(es, es_extension_id);
+
+ if (plan_advice == NULL)
+ {
+ plan_advice = palloc0_object(bool);
+ SetExplainExtensionState(es, es_extension_id, plan_advice);
+ }
+
+ *plan_advice = defGetBoolean(opt);
+}
+
+/*
+ * Display a string that is likely to consist of multiple lines in EXPLAIN
+ * output.
+ */
+static void
+pg_plan_advice_explain_text_multiline(ExplainState *es, char *qlabel,
+ char *value)
+{
+ char *s;
+
+ /* For non-text formats, it's best not to add any special handling. */
+ if (es->format != EXPLAIN_FORMAT_TEXT)
+ {
+ ExplainPropertyText(qlabel, value, es);
+ return;
+ }
+
+ /* In text format, if there is no data, display nothing. */
+ if (*qlabel == '\0')
+ return;
+
+ /*
+ * It looks nicest to indent each line of the advice separately, beginning
+ * on the line below the label.
+ */
+ ExplainIndentText(es);
+ appendStringInfo(es->str, "%s:\n", qlabel);
+ es->indent++;
+ while ((s = strchr(value, '\n')) != NULL)
+ {
+ ExplainIndentText(es);
+ appendBinaryStringInfo(es->str, value, (s - value) + 1);
+ value = s + 1;
+ }
+
+ /* Don't interpret a terminal newline as a request for an empty line. */
+ if (*value != '\0')
+ {
+ ExplainIndentText(es);
+ appendStringInfo(es->str, "%s\n", value);
+ }
+
+ es->indent--;
+}
+
+/*
+ * Add advice feedback to the EXPLAIN output.
+ */
+static void
+pg_plan_advice_explain_feedback(ExplainState *es, List *feedback)
+{
+ StringInfoData buf;
+
+ initStringInfo(&buf);
+ foreach_node(DefElem, item, feedback)
+ {
+ int flags = defGetInt32(item);
+
+ appendStringInfo(&buf, "%s /* ", item->defname);
+ if ((flags & PGPA_TE_MATCH_FULL) != 0)
+ {
+ Assert((flags & PGPA_TE_MATCH_PARTIAL) != 0);
+ appendStringInfo(&buf, "matched");
+ }
+ else if ((flags & PGPA_TE_MATCH_PARTIAL) != 0)
+ appendStringInfo(&buf, "partially matched");
+ else
+ appendStringInfo(&buf, "not matched");
+ if ((flags & PGPA_TE_INAPPLICABLE) != 0)
+ appendStringInfo(&buf, ", inapplicable");
+ if ((flags & PGPA_TE_CONFLICTING) != 0)
+ appendStringInfo(&buf, ", conflicting");
+ if ((flags & PGPA_TE_FAILED) != 0)
+ appendStringInfo(&buf, ", failed");
+ appendStringInfo(&buf, " */\n");
+ }
+
+ pg_plan_advice_explain_text_multiline(es, "Supplied Plan Advice",
+ buf.data);
+}
+
+/*
+ * Add relevant details, if any, to the EXPLAIN output for a single plan.
+ */
+static void
+pg_plan_advice_explain_per_plan_hook(PlannedStmt *plannedstmt,
+ IntoClause *into,
+ ExplainState *es,
+ const char *queryString,
+ ParamListInfo params,
+ QueryEnvironment *queryEnv)
+{
+ bool *plan_advice = GetExplainExtensionState(es, es_extension_id);
+ DefElem *pgpa_item;
+ List *pgpa_list;
+
+ if (prev_explain_per_plan)
+ prev_explain_per_plan(plannedstmt, into, es, queryString, params,
+ queryEnv);
+
+ /* Find any data pgpa_planner_shutdown stashed in the PlannedStmt. */
+ pgpa_item = find_defelem_by_defname(plannedstmt->extension_state,
+ "pg_plan_advice");
+ pgpa_list = pgpa_item == NULL ? NULL : (List *) pgpa_item->arg;
+
+ /*
+ * By default, if there is a record of attempting to apply advice during
+ * query planning, we always output that information, but the user can set
+ * pg_plan_advice.always_explain_supplied_advice = false to suppress that
+ * behavior. If they do, we'll only display it when the PLAN_ADVICE option
+ * was specified and not set to false.
+ *
+ * NB: If we're explaining a query planned beforehand -- i.e. a prepared
+ * statement -- the application of query advice may not have been
+ * recorded, and therefore this won't be able to show anything.
+ */
+ if (pgpa_list != NULL && (pg_plan_advice_always_explain_supplied_advice ||
+ (plan_advice != NULL && *plan_advice)))
+ {
+ DefElem *feedback;
+
+ feedback = find_defelem_by_defname(pgpa_list, "feedback");
+ if (feedback != NULL)
+ pg_plan_advice_explain_feedback(es, (List *) feedback->arg);
+ }
+
+ /*
+ * If the PLAN_ADVICE option was specified -- and not sent to FALSE --
+ * show generated advice.
+ */
+ if (plan_advice != NULL && *plan_advice)
+ {
+ DefElem *advice_string_item;
+ char *advice_string;
+
+ advice_string_item =
+ find_defelem_by_defname(pgpa_list, "advice_string");
+ if (advice_string_item != NULL)
+ {
+ /* Advice has already been generated; we can reuse it. */
+ advice_string = strVal(advice_string_item->arg);
+ }
+ else
+ {
+ pgpa_plan_walker_context walker;
+ StringInfoData buf;
+ pgpa_identifier *rt_identifiers;
+
+ /* Advice not yet generated; do that now. */
+ pgpa_plan_walker(&walker, plannedstmt);
+ rt_identifiers =
+ pgpa_create_identifiers_for_planned_stmt(plannedstmt);
+ initStringInfo(&buf);
+ pgpa_output_advice(&buf, &walker, rt_identifiers);
+ advice_string = buf.data;
+ }
+
+ if (advice_string[0] != '\0')
+ pg_plan_advice_explain_text_multiline(es, "Generated Plan Advice",
+ advice_string);
+ }
+}
+
+/*
+ * Check hook for pg_plan_advice.advice
+ */
+static bool
+pg_plan_advice_advice_check_hook(char **newval, void **extra, GucSource source)
+{
+ MemoryContext oldcontext;
+ MemoryContext tmpcontext;
+ char *error;
+
+ if (*newval == NULL)
+ return true;
+
+ tmpcontext = AllocSetContextCreate(CurrentMemoryContext,
+ "pg_plan_advice.advice",
+ ALLOCSET_DEFAULT_SIZES);
+ oldcontext = MemoryContextSwitchTo(tmpcontext);
+
+ /*
+ * It would be nice to save the parse tree that we construct here for
+ * eventual use when planning with this advice, but *extra can only point
+ * to a single guc_malloc'd chunk, and our parse tree involves an
+ * arbitrary number of memory allocations.
+ */
+ (void) pgpa_parse(*newval, &error);
+
+ if (error != NULL)
+ {
+ GUC_check_errdetail("Could not parse advice: %s", error);
+ return false;
+ }
+
+ MemoryContextSwitchTo(oldcontext);
+ MemoryContextDelete(tmpcontext);
+
+ return true;
+}
+
+/*
+ * Search a list of DefElem objects for a given defname.
+ */
+static DefElem *
+find_defelem_by_defname(List *deflist, char *defname)
+{
+ foreach_node(DefElem, item, deflist)
+ {
+ if (strcmp(item->defname, defname) == 0)
+ return item;
+ }
+
+ return NULL;
+}
--- /dev/null
+# pg_plan_advice extension
+comment = 'help the planner get the right plan'
+default_version = '1.0'
+module_pathname = '$libdir/pg_plan_advice'
+relocatable = true
--- /dev/null
+/*-------------------------------------------------------------------------
+ *
+ * pg_plan_advice.h
+ * main header file for pg_plan_advice contrib module
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pg_plan_advice.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_PLAN_ADVICE_H
+#define PG_PLAN_ADVICE_H
+
+#include "nodes/plannodes.h"
+#include "storage/lwlock.h"
+#include "utils/dsa.h"
+
+typedef struct pgpa_shared_state
+{
+ LWLock lock;
+ int dsa_tranche;
+ dsa_handle area;
+ dsa_pointer shared_collector;
+} pgpa_shared_state;
+
+/* GUC variables */
+extern int pg_plan_advice_local_collection_limit;
+extern int pg_plan_advice_shared_collection_limit;
+extern char *pg_plan_advice_advice;
+
+/* Function prototypes */
+extern MemoryContext pg_plan_advice_get_mcxt(void);
+extern pgpa_shared_state *pg_plan_advice_attach(void);
+extern dsa_area *pg_plan_advice_dsa_area(void);
+
+#endif
--- /dev/null
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_ast.c
+ * additional supporting code related to plan advice parsing
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_ast.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_ast.h"
+
+#include "funcapi.h"
+#include "utils/array.h"
+#include "utils/builtins.h"
+
+static bool pgpa_identifiers_cover_target(int nrids, pgpa_identifier *rids,
+ pgpa_advice_target *target,
+ bool *rids_used);
+
+/*
+ * Get a C string that corresponds to the specified advice tag.
+ */
+char *
+pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag)
+{
+ switch (advice_tag)
+ {
+ case PGPA_TAG_BITMAP_HEAP_SCAN:
+ return "BITMAP_HEAP_SCAN";
+ case PGPA_TAG_FOREIGN_JOIN:
+ return "FOREIGN_JOIN";
+ case PGPA_TAG_GATHER:
+ return "GATHER";
+ case PGPA_TAG_GATHER_MERGE:
+ return "GATHER_MERGE";
+ case PGPA_TAG_HASH_JOIN:
+ return "HASH_JOIN";
+ case PGPA_TAG_INDEX_ONLY_SCAN:
+ return "INDEX_ONLY_SCAN";
+ case PGPA_TAG_INDEX_SCAN:
+ return "INDEX_SCAN";
+ case PGPA_TAG_JOIN_ORDER:
+ return "JOIN_ORDER";
+ case PGPA_TAG_MERGE_JOIN_MATERIALIZE:
+ return "MERGE_JOIN_MATERIALIZE";
+ case PGPA_TAG_MERGE_JOIN_PLAIN:
+ return "MERGE_JOIN_PLAIN";
+ case PGPA_TAG_NESTED_LOOP_MATERIALIZE:
+ return "NESTED_LOOP_MATERIALIZE";
+ case PGPA_TAG_NESTED_LOOP_MEMOIZE:
+ return "NESTED_LOOP_MEMOIZE";
+ case PGPA_TAG_NESTED_LOOP_PLAIN:
+ return "NESTED_LOOP_PLAIN";
+ case PGPA_TAG_NO_GATHER:
+ return "NO_GATHER";
+ case PGPA_TAG_PARTITIONWISE:
+ return "PARTITIONWISE";
+ case PGPA_TAG_SEMIJOIN_NON_UNIQUE:
+ return "SEMIJOIN_NON_UNIQUE";
+ case PGPA_TAG_SEMIJOIN_UNIQUE:
+ return "SEMIJOIN_UNIQUE";
+ case PGPA_TAG_SEQ_SCAN:
+ return "SEQ_SCAN";
+ case PGPA_TAG_TID_SCAN:
+ return "TID_SCAN";
+ }
+
+ pg_unreachable();
+ return NULL;
+}
+
+/*
+ * Convert an advice tag, formatted as a string that has already been
+ * downcased as appropriate, to a pgpa_advice_tag_type.
+ *
+ * If we succeed, set *fail = false and return the result; if we fail,
+ * set *fail = true and reurn an arbitrary value.
+ */
+pgpa_advice_tag_type
+pgpa_parse_advice_tag(const char *tag, bool *fail)
+{
+ *fail = false;
+
+ switch (tag[0])
+ {
+ case 'b':
+ if (strcmp(tag, "bitmap_heap_scan") == 0)
+ return PGPA_TAG_BITMAP_HEAP_SCAN;
+ break;
+ case 'f':
+ if (strcmp(tag, "foreign_join") == 0)
+ return PGPA_TAG_FOREIGN_JOIN;
+ break;
+ case 'g':
+ if (strcmp(tag, "gather") == 0)
+ return PGPA_TAG_GATHER;
+ if (strcmp(tag, "gather_merge") == 0)
+ return PGPA_TAG_GATHER_MERGE;
+ break;
+ case 'h':
+ if (strcmp(tag, "hash_join") == 0)
+ return PGPA_TAG_HASH_JOIN;
+ break;
+ case 'i':
+ if (strcmp(tag, "index_scan") == 0)
+ return PGPA_TAG_INDEX_SCAN;
+ if (strcmp(tag, "index_only_scan") == 0)
+ return PGPA_TAG_INDEX_ONLY_SCAN;
+ break;
+ case 'j':
+ if (strcmp(tag, "join_order") == 0)
+ return PGPA_TAG_JOIN_ORDER;
+ break;
+ case 'm':
+ if (strcmp(tag, "merge_join_materialize") == 0)
+ return PGPA_TAG_MERGE_JOIN_MATERIALIZE;
+ if (strcmp(tag, "merge_join_plain") == 0)
+ return PGPA_TAG_MERGE_JOIN_PLAIN;
+ break;
+ case 'n':
+ if (strcmp(tag, "nested_loop_materialize") == 0)
+ return PGPA_TAG_NESTED_LOOP_MATERIALIZE;
+ if (strcmp(tag, "nested_loop_memoize") == 0)
+ return PGPA_TAG_NESTED_LOOP_MEMOIZE;
+ if (strcmp(tag, "nested_loop_plain") == 0)
+ return PGPA_TAG_NESTED_LOOP_PLAIN;
+ if (strcmp(tag, "no_gather") == 0)
+ return PGPA_TAG_NO_GATHER;
+ break;
+ case 'p':
+ if (strcmp(tag, "partitionwise") == 0)
+ return PGPA_TAG_PARTITIONWISE;
+ break;
+ case 's':
+ if (strcmp(tag, "semijoin_non_unique") == 0)
+ return PGPA_TAG_SEMIJOIN_NON_UNIQUE;
+ if (strcmp(tag, "semijoin_unique") == 0)
+ return PGPA_TAG_SEMIJOIN_UNIQUE;
+ if (strcmp(tag, "seq_scan") == 0)
+ return PGPA_TAG_SEQ_SCAN;
+ break;
+ case 't':
+ if (strcmp(tag, "tid_scan") == 0)
+ return PGPA_TAG_TID_SCAN;
+ break;
+ }
+
+ /* didn't work out */
+ *fail = true;
+
+ /* return an arbitrary value to unwind the call stack */
+ return PGPA_TAG_SEQ_SCAN;
+}
+
+/*
+ * Format a pgpa_advice_target as a string and append result to a StringInfo.
+ */
+void
+pgpa_format_advice_target(StringInfo str, pgpa_advice_target *target)
+{
+ if (target->ttype != PGPA_TARGET_IDENTIFIER)
+ {
+ bool first = true;
+ char *delims;
+
+ if (target->ttype == PGPA_TARGET_UNORDERED_LIST)
+ delims = "{}";
+ else
+ delims = "()";
+
+ appendStringInfoChar(str, delims[0]);
+ foreach_ptr(pgpa_advice_target, child_target, target->children)
+ {
+ if (first)
+ first = false;
+ else
+ appendStringInfoChar(str, ' ');
+ pgpa_format_advice_target(str, child_target);
+ }
+ appendStringInfoChar(str, delims[1]);
+ }
+ else
+ {
+ const char *rt_identifier;
+
+ rt_identifier = pgpa_identifier_string(&target->rid);
+ appendStringInfoString(str, rt_identifier);
+ }
+}
+
+/*
+ * Format a pgpa_index_target as a string and append result to a StringInfo.
+ */
+void
+pgpa_format_index_target(StringInfo str, pgpa_index_target *itarget)
+{
+ if (itarget->itype != PGPA_INDEX_NAME)
+ {
+ bool first = true;
+
+ if (itarget->itype == PGPA_INDEX_AND)
+ appendStringInfoString(str, "&&(");
+ else
+ appendStringInfoString(str, "||(");
+
+ foreach_ptr(pgpa_index_target, child_target, itarget->children)
+ {
+ if (first)
+ first = false;
+ else
+ appendStringInfoChar(str, ' ');
+ pgpa_format_index_target(str, child_target);
+ }
+ appendStringInfoChar(str, ')');
+ }
+ else
+ {
+ if (itarget->indnamespace != NULL)
+ appendStringInfo(str, "%s.",
+ quote_identifier(itarget->indnamespace));
+ appendStringInfoString(str, quote_identifier(itarget->indname));
+ }
+}
+
+/*
+ * Determine whether two pgpa_index_target objects are exactly identical.
+ */
+bool
+pgpa_index_targets_equal(pgpa_index_target *i1, pgpa_index_target *i2)
+{
+ if (i1->itype != i2->itype)
+ return false;
+
+ if (i1->itype == PGPA_INDEX_NAME)
+ {
+ /* indnamespace can be NULL, and two NULL values are equal */
+ if ((i1->indnamespace != NULL || i2->indnamespace != NULL) &&
+ (i1->indnamespace == NULL || i2->indnamespace == NULL ||
+ strcmp(i1->indnamespace, i2->indnamespace) != 0))
+ return false;
+ if (strcmp(i1->indname, i2->indname) != 0)
+ return false;
+ }
+ else
+ {
+ int i1_length = list_length(i1->children);
+
+ if (i1_length != list_length(i2->children))
+ return false;
+ for (int n = 0; n < i1_length; ++n)
+ {
+ pgpa_index_target *c1 = list_nth(i1->children, n);
+ pgpa_index_target *c2 = list_nth(i2->children, n);
+
+ if (!pgpa_index_targets_equal(c1, c2))
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/*
+ * Check whether an identifier matches an any part of an advice target.
+ */
+bool
+pgpa_identifier_matches_target(pgpa_identifier *rid, pgpa_advice_target *target)
+{
+ /* For non-identifiers, check all descendents. */
+ if (target->ttype != PGPA_TARGET_IDENTIFIER)
+ {
+ foreach_ptr(pgpa_advice_target, child_target, target->children)
+ {
+ if (pgpa_identifier_matches_target(rid, child_target))
+ return true;
+ }
+ return false;
+ }
+
+ if (strcmp(rid->alias_name, target->rid.alias_name) != 0)
+ return false;
+ if (rid->occurrence != target->rid.occurrence)
+ return false;
+
+ /*
+ * The identifier must specify a schema, but the target may leave the
+ * schema NULL to match anything.
+ */
+ if (target->rid.partnsp != NULL &&
+ strcmp(rid->partnsp, target->rid.partnsp) != 0)
+ return false;
+
+
+ /*
+ * These fields can be NULL on either side, but NULL only matches another
+ * NULL.
+ */
+ if (!strings_equal_or_both_null(rid->partrel, target->rid.partrel))
+ return false;
+ if (!strings_equal_or_both_null(rid->plan_name, target->rid.plan_name))
+ return false;
+
+ return true;
+}
+
+/*
+ * Match identifiers to advice targets and return an enum value indicating
+ * the relationship between the set of keys and the set of targets.
+ *
+ * See the comments for pgpa_itm_type.
+ */
+pgpa_itm_type
+pgpa_identifiers_match_target(int nrids, pgpa_identifier *rids,
+ pgpa_advice_target *target)
+{
+ bool all_rids_used = true;
+ bool any_rids_used = false;
+ bool all_targets_used;
+ bool *rids_used = palloc0_array(bool, nrids);
+
+ all_targets_used =
+ pgpa_identifiers_cover_target(nrids, rids, target, rids_used);
+
+ for (int i = 0; i < nrids; ++i)
+ {
+ if (rids_used[i])
+ any_rids_used = true;
+ else
+ all_rids_used = false;
+ }
+
+ if (all_rids_used)
+ {
+ if (all_targets_used)
+ return PGPA_ITM_EQUAL;
+ else
+ return PGPA_ITM_KEYS_ARE_SUBSET;
+ }
+ else
+ {
+ if (all_targets_used)
+ return PGPA_ITM_TARGETS_ARE_SUBSET;
+ else if (any_rids_used)
+ return PGPA_ITM_INTERSECTING;
+ else
+ return PGPA_ITM_DISJOINT;
+ }
+}
+
+/*
+ * Returns true if every target or sub-target is matched by at least one
+ * identifier, and otherwise false.
+ *
+ * Also sets rids_used[i] = true for each idenifier that matches at least one
+ * target.
+ */
+static bool
+pgpa_identifiers_cover_target(int nrids, pgpa_identifier *rids,
+ pgpa_advice_target *target, bool *rids_used)
+{
+ bool result = false;
+
+ if (target->ttype != PGPA_TARGET_IDENTIFIER)
+ {
+ result = true;
+
+ foreach_ptr(pgpa_advice_target, child_target, target->children)
+ {
+ if (!pgpa_identifiers_cover_target(nrids, rids, child_target,
+ rids_used))
+ result = false;
+ }
+ }
+ else
+ {
+ for (int i = 0; i < nrids; ++i)
+ {
+ if (pgpa_identifier_matches_target(&rids[i], target))
+ {
+ rids_used[i] = true;
+ result = true;
+ }
+ }
+ }
+
+ return result;
+}
--- /dev/null
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_ast.h
+ * abstract syntax trees for plan advice, plus parser/scanner support
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_ast.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_AST_H
+#define PGPA_AST_H
+
+#include "pgpa_identifier.h"
+
+#include "nodes/pg_list.h"
+
+/*
+ * Advice items generally take the form SOME_TAG(item [...]), where an item
+ * can take various forms. The simplest case is a relation identifier, but
+ * some tags allow sublists, and JOIN_ORDER() allows both ordered and unordered
+ * sublists.
+ */
+typedef enum
+{
+ PGPA_TARGET_IDENTIFIER, /* relation identifier */
+ PGPA_TARGET_ORDERED_LIST, /* (item ...) */
+ PGPA_TARGET_UNORDERED_LIST /* {item ...} */
+} pgpa_target_type;
+
+/*
+ * When an advice item describes a bitmap index scan, it may need to describe
+ * the use of multiple indexes.
+ */
+typedef enum
+{
+ PGPA_INDEX_NAME, /* index schema + name */
+ PGPA_INDEX_AND, /* &&(item ...) */
+ PGPA_INDEX_OR /* ||(item ...) */
+} pgpa_index_type;
+
+/*
+ * An index specification. We use this for INDEX_SCAN, INDEX_ONLY_SCAN,
+ * and BITMAP_HEAP_SCAN advice, but in the former two cases, the target must
+ * be of type PGPA_INDEX_NAME.
+ */
+typedef struct pgpa_index_target
+{
+ pgpa_index_type itype;
+
+ /* Index schem and name, when itype == PGPA_INDEX_NAME */
+ char *indnamespace;
+ char *indname;
+
+ /* List of pgpa_index_target objects, when itype != PGPA_INDEX_NAME */
+ List *children;
+} pgpa_index_target;
+
+/*
+ * A single item about which advice is being given, which could be either
+ * a relation identifier that we want to break out into its constituent fields,
+ * or a sublist of some kind.
+ */
+typedef struct pgpa_advice_target
+{
+ pgpa_target_type ttype;
+
+ /*
+ * This field is meaningful when ttype is PGPA_TARGET_IDENTIFIER.
+ *
+ * All identifiers must have an alias name and an occurrence number; the
+ * remaining fields can be NULL. Note that it's possible to specify a
+ * partition name without a partition schema, but not the reverse.
+ */
+ pgpa_identifier rid;
+
+ /*
+ * This field is set when ttype is PPGA_TARGET_IDENTIFIER and the advice
+ * tag is PGPA_TAG_INDEX_SCAN, PGPA_TAG_INDEX_ONLY_SCAN, or
+ * PGPA_TAG_BITMAP_HEAP_SCAN.
+ */
+ pgpa_index_target *itarget;
+
+ /*
+ * When the ttype is PGPA_TARGET_<anything>_LIST, this field contains a
+ * list of additional pgpa_advice_target objects. Otherwise, it is unused.
+ */
+ List *children;
+} pgpa_advice_target;
+
+/*
+ * These are all the kinds of advice that we know how to parse. If a keyword
+ * is found at the top level, it must be in this list.
+ *
+ * If you change anything here, also update pgpa_parse_advice_tag and
+ * pgpa_cstring_advice_tag.
+ */
+typedef enum pgpa_advice_tag_type
+{
+ PGPA_TAG_BITMAP_HEAP_SCAN,
+ PGPA_TAG_FOREIGN_JOIN,
+ PGPA_TAG_GATHER,
+ PGPA_TAG_GATHER_MERGE,
+ PGPA_TAG_HASH_JOIN,
+ PGPA_TAG_INDEX_ONLY_SCAN,
+ PGPA_TAG_INDEX_SCAN,
+ PGPA_TAG_JOIN_ORDER,
+ PGPA_TAG_MERGE_JOIN_MATERIALIZE,
+ PGPA_TAG_MERGE_JOIN_PLAIN,
+ PGPA_TAG_NESTED_LOOP_MATERIALIZE,
+ PGPA_TAG_NESTED_LOOP_MEMOIZE,
+ PGPA_TAG_NESTED_LOOP_PLAIN,
+ PGPA_TAG_NO_GATHER,
+ PGPA_TAG_PARTITIONWISE,
+ PGPA_TAG_SEMIJOIN_NON_UNIQUE,
+ PGPA_TAG_SEMIJOIN_UNIQUE,
+ PGPA_TAG_SEQ_SCAN,
+ PGPA_TAG_TID_SCAN
+} pgpa_advice_tag_type;
+
+/*
+ * An item of advice, meaning a tag and the list of all targets to which
+ * it is being applied.
+ *
+ * "targets" is a list of pgpa_advice_target objects.
+ *
+ * The List returned from pgpa_yyparse is list of pgpa_advice_item objects.
+ */
+typedef struct pgpa_advice_item
+{
+ pgpa_advice_tag_type tag;
+ List *targets;
+} pgpa_advice_item;
+
+/*
+ * Result of comparing an array of pgpa_relation_identifier objects to a
+ * pgpa_advice_target.
+ *
+ * PGPA_ITM_EQUAL means all targets are matched by some identifier, and
+ * all identifiers were matched to a target.
+ *
+ * PGPA_ITM_KEYS_ARE_SUBSET means that all identifiers matched to a target,
+ * but there were leftover targets. Generally, this means that the advice is
+ * looking to apply to all of the rels we have plus some additional ones that
+ * we don't have.
+ *
+ * PGPA_ITM_TARGETS_ARE_SUBSET means that all targets are matched by an
+ * identifiers, but there were leftover identifiers. Generally, this means
+ * that the advice is looking to apply to some but not all of the rels we have.
+ *
+ * PGPA_ITM_INTERSECTING means that some identifeirs and targets were matched,
+ * but neither all identifiers nor all targets could be matched to items in
+ * the other set.
+ *
+ * PGPA_ITM_DISJOINT means that no matches between identifeirs and targets were
+ * found.
+ */
+typedef enum
+{
+ PGPA_ITM_EQUAL,
+ PGPA_ITM_KEYS_ARE_SUBSET,
+ PGPA_ITM_TARGETS_ARE_SUBSET,
+ PGPA_ITM_INTERSECTING,
+ PGPA_ITM_DISJOINT
+} pgpa_itm_type;
+
+/* for pgpa_scanner.l and pgpa_parser.y */
+union YYSTYPE;
+#ifndef YY_TYPEDEF_YY_SCANNER_T
+#define YY_TYPEDEF_YY_SCANNER_T
+typedef void *yyscan_t;
+#endif
+
+/* in pgpa_scanner.l */
+extern int pgpa_yylex(union YYSTYPE *yylval_param, List **result,
+ char **parse_error_msg_p, yyscan_t yyscanner);
+extern void pgpa_yyerror(List **result, char **parse_error_msg_p,
+ yyscan_t yyscanner,
+ const char *message);
+extern void pgpa_scanner_init(const char *str, yyscan_t *yyscannerp);
+extern void pgpa_scanner_finish(yyscan_t yyscanner);
+
+/* in pgpa_parser.y */
+extern int pgpa_yyparse(List **result, char **parse_error_msg_p,
+ yyscan_t yyscanner);
+extern List *pgpa_parse(const char *advice_string, char **error_p);
+
+/* in pgpa_ast.c */
+extern char *pgpa_cstring_advice_tag(pgpa_advice_tag_type advice_tag);
+extern bool pgpa_identifier_matches_target(pgpa_identifier *rid,
+ pgpa_advice_target *target);
+extern pgpa_itm_type pgpa_identifiers_match_target(int nrids,
+ pgpa_identifier *rids,
+ pgpa_advice_target *target);
+extern bool pgpa_index_targets_equal(pgpa_index_target *i1,
+ pgpa_index_target *i2);
+extern pgpa_advice_tag_type pgpa_parse_advice_tag(const char *tag, bool *fail);
+extern void pgpa_format_advice_target(StringInfo str,
+ pgpa_advice_target *target);
+extern void pgpa_format_index_target(StringInfo str,
+ pgpa_index_target *itarget);
+
+#endif
--- /dev/null
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_collector.c
+ * collect advice into backend-local or shared memory
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_collector.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pg_plan_advice.h"
+#include "pgpa_collector.h"
+
+#include "datatype/timestamp.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "nodes/pg_list.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/timestamp.h"
+
+PG_FUNCTION_INFO_V1(pg_clear_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_clear_collected_shared_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_local_advice);
+PG_FUNCTION_INFO_V1(pg_get_collected_shared_advice);
+
+#define ADVICE_CHUNK_SIZE 1024
+#define ADVICE_CHUNK_ARRAY_SIZE 64
+
+#define PG_GET_ADVICE_COLUMNS 7
+
+/*
+ * Advice extracted from one query plan, together with the query string
+ * and various other identifying details.
+ */
+typedef struct pgpa_collected_advice
+{
+ Oid userid; /* user OID */
+ Oid dbid; /* database OID */
+ uint64 queryid; /* query identifier */
+ TimestampTz timestamp; /* query timestamp */
+ int advice_offset; /* start of advice in textual data */
+ char textual_data[FLEXIBLE_ARRAY_MEMBER];
+} pgpa_collected_advice;
+
+/*
+ * A bunch of pointers to pgpa_collected_advice objects, stored in
+ * backend-local memory.
+ */
+typedef struct pgpa_local_advice_chunk
+{
+ pgpa_collected_advice *entries[ADVICE_CHUNK_SIZE];
+} pgpa_local_advice_chunk;
+
+/*
+ * Information about all of the pgpa_collected_advice objects that we're
+ * storing in local memory.
+ *
+ * We assign consecutive IDs, starting from 0, to each pgpa_collected_advice
+ * object that we store. The actual storage is an array of chunks, which
+ * helps keep memcpy() overhead low when we start discarding older data.
+ */
+typedef struct pgpa_local_advice
+{
+ uint64 next_id;
+ uint64 oldest_id;
+ uint64 base_id;
+ int chunk_array_allocated_size;
+ pgpa_local_advice_chunk **chunks;
+} pgpa_local_advice;
+
+/*
+ * Just like pgpa_local_advice_chunk, but stored in a dynamic shared area,
+ * so we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgpa_shared_advice_chunk
+{
+ dsa_pointer entries[ADVICE_CHUNK_SIZE];
+} pgpa_shared_advice_chunk;
+
+/*
+ * Just like pgpa_local_advice, but stored in a dynamic shared area, so
+ * we must use dsa_pointer instead of native pointers.
+ */
+typedef struct pgpa_shared_advice
+{
+ uint64 next_id;
+ uint64 oldest_id;
+ uint64 base_id;
+ int chunk_array_allocated_size;
+ dsa_pointer chunks;
+} pgpa_shared_advice;
+
+/* Pointers to local and shared collectors */
+static pgpa_local_advice *local_collector = NULL;
+static pgpa_shared_advice *shared_collector = NULL;
+
+/* Static functions */
+static pgpa_collected_advice *pgpa_make_collected_advice(Oid userid,
+ Oid dbid,
+ uint64 queryId,
+ TimestampTz timestamp,
+ const char *query_string,
+ const char *advice_string,
+ dsa_area *area,
+ dsa_pointer *result);
+static void pgpa_store_local_advice(pgpa_collected_advice *ca);
+static void pgpa_trim_local_advice(int limit);
+static void pgpa_store_shared_advice(dsa_pointer ca_pointer);
+static void pgpa_trim_shared_advice(dsa_area *area, int limit);
+
+/* Helper function to extract the query string from pgpa_collected_advice */
+static inline const char *
+query_string(pgpa_collected_advice *ca)
+{
+ return ca->textual_data;
+}
+
+/* Helper function to extract the advice string from pgpa_collected_advice */
+static inline const char *
+advice_string(pgpa_collected_advice *ca)
+{
+ return ca->textual_data + ca->advice_offset;
+}
+
+/*
+ * Store collected query advice into the local or shared advice collector,
+ * as appropriate.
+ */
+void
+pgpa_collect_advice(uint64 queryId, const char *query_string,
+ const char *advice_string)
+{
+ Oid userid = GetUserId();
+ Oid dbid = MyDatabaseId;
+ TimestampTz now = GetCurrentTimestamp();
+
+ if (pg_plan_advice_local_collection_limit > 0)
+ {
+ pgpa_collected_advice *ca;
+ MemoryContext oldcontext;
+
+ oldcontext = MemoryContextSwitchTo(pg_plan_advice_get_mcxt());
+ ca = pgpa_make_collected_advice(userid, dbid, queryId, now,
+ query_string, advice_string,
+ NULL, NULL);
+ pgpa_store_local_advice(ca);
+ MemoryContextSwitchTo(oldcontext);
+ }
+
+ if (pg_plan_advice_shared_collection_limit > 0)
+ {
+ dsa_area *area = pg_plan_advice_dsa_area();
+ dsa_pointer ca_pointer;
+
+ pgpa_make_collected_advice(userid, dbid, queryId, now,
+ query_string, advice_string, area,
+ &ca_pointer);
+ pgpa_store_shared_advice(ca_pointer);
+ }
+}
+
+/*
+ * Allocate and fill a new pgpa_collected_advice object.
+ *
+ * If area != NULL, it is used to allocate the new object, and the resulting
+ * dsa_pointer is returned via *result.
+ *
+ * If area == NULL, the new object is allocated in the current memory context,
+ * and result is not examined or modified.
+ */
+static pgpa_collected_advice *
+pgpa_make_collected_advice(Oid userid, Oid dbid, uint64 queryId,
+ TimestampTz timestamp,
+ const char *query_string,
+ const char *advice_string,
+ dsa_area *area, dsa_pointer *result)
+{
+ size_t query_string_length = strlen(query_string) + 1;
+ size_t advice_string_length = strlen(advice_string) + 1;
+ size_t total_length;
+ pgpa_collected_advice *ca;
+
+ total_length = offsetof(pgpa_collected_advice, textual_data)
+ + query_string_length + advice_string_length;
+
+ if (area == NULL)
+ ca = palloc(total_length);
+ else
+ {
+ *result = dsa_allocate(area, total_length);
+ ca = dsa_get_address(area, *result);
+ }
+
+ ca->userid = GetUserId();
+ ca->dbid = MyDatabaseId;
+ ca->queryid = queryId;
+ ca->timestamp = timestamp;
+ ca->advice_offset = query_string_length;
+
+ memcpy(ca->textual_data, query_string, query_string_length);
+ memcpy(&ca->textual_data[ca->advice_offset],
+ advice_string, advice_string_length);
+
+ return ca;
+}
+
+/*
+ * Add a pg_collected_advice object to our backend-local advice collection.
+ *
+ * Caller is responsible for switching to the appropriate memory context;
+ * the provided object should have been allocated in that same context.
+ */
+static void
+pgpa_store_local_advice(pgpa_collected_advice *ca)
+{
+ uint64 chunk_number;
+ uint64 chunk_offset;
+ pgpa_local_advice *la = local_collector;
+
+ /* If the local advice collector isn't initialized yet, do that now. */
+ if (la == NULL)
+ {
+ la = palloc0(sizeof(pgpa_local_advice));
+ la->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+ la->chunks = palloc0_array(pgpa_local_advice_chunk *,
+ la->chunk_array_allocated_size);
+ local_collector = la;
+ }
+
+ /* Compute chunk and offset at which to store this advice. */
+ chunk_number = (la->next_id - la->base_id) / ADVICE_CHUNK_SIZE;
+ chunk_offset = (la->next_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+ /* Extend chunk array, if needed. */
+ if (chunk_number >= la->chunk_array_allocated_size)
+ {
+ int new_size;
+
+ new_size = la->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+ la->chunks = repalloc0_array(la->chunks,
+ pgpa_local_advice_chunk *,
+ la->chunk_array_allocated_size,
+ new_size);
+ la->chunk_array_allocated_size = new_size;
+ }
+
+ /* Allocate new chunk, if needed. */
+ if (la->chunks[chunk_number] == NULL)
+ la->chunks[chunk_number] = palloc0_object(pgpa_local_advice_chunk);
+
+ /* Save pointer and bump next-id counter. */
+ Assert(la->chunks[chunk_number]->entries[chunk_offset] == NULL);
+ la->chunks[chunk_number]->entries[chunk_offset] = ca;
+ ++la->next_id;
+
+ /* If we've exceeded the storage limit, discard old data. */
+ pgpa_trim_local_advice(pg_plan_advice_local_collection_limit);
+}
+
+/*
+ * Add a pg_collected_advice object to the shared advice collection.
+ *
+ * 'ca_pointer' should have been allocated from the pg_plan_advice DSA area
+ * and should point to an object of type pgpa_collected_advice.
+ */
+static void
+pgpa_store_shared_advice(dsa_pointer ca_pointer)
+{
+ uint64 chunk_number;
+ uint64 chunk_offset;
+ pgpa_shared_state *state = pg_plan_advice_attach();
+ dsa_area *area = pg_plan_advice_dsa_area();
+ pgpa_shared_advice *sa = shared_collector;
+ dsa_pointer *chunk_array;
+ pgpa_shared_advice_chunk *chunk;
+
+ /* Lock the shared state. */
+ LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+ /*
+ * If we're not attached to the shared advice collector yet, fix that now.
+ * If we're the first ones to attach, we may need to create the object.
+ */
+ if (sa == NULL)
+ {
+ if (state->shared_collector == InvalidDsaPointer)
+ state->shared_collector =
+ dsa_allocate0(area, sizeof(pgpa_shared_advice));
+ shared_collector = sa = dsa_get_address(area, state->shared_collector);
+ }
+
+ /*
+ * It's possible that some other backend may have succeeded in creating
+ * the main collector object but failed to allocate an initial chunk
+ * array, so we must be prepared to allocate the chunk array here whether
+ * or not we created the collector object.
+ */
+ if (shared_collector->chunk_array_allocated_size == 0)
+ {
+ sa->chunks =
+ dsa_allocate0(area,
+ sizeof(dsa_pointer) * ADVICE_CHUNK_ARRAY_SIZE);
+ sa->chunk_array_allocated_size = ADVICE_CHUNK_ARRAY_SIZE;
+ }
+
+ /* Compute chunk and offset at which to store this advice. */
+ chunk_number = (sa->next_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+ chunk_offset = (sa->next_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+ /* Get the address of the chunk array and, if needed, extend it. */
+ if (chunk_number >= sa->chunk_array_allocated_size)
+ {
+ int new_size;
+ dsa_pointer new_chunks;
+
+ /*
+ * DSA can't enlarge an existing allocation, so we must make a new
+ * allocation and copy data over.
+ */
+ new_size = sa->chunk_array_allocated_size + ADVICE_CHUNK_ARRAY_SIZE;
+ new_chunks = dsa_allocate0(area, sizeof(dsa_pointer) * new_size);
+ chunk_array = dsa_get_address(area, new_chunks);
+ memcpy(chunk_array, dsa_get_address(area, sa->chunks),
+ sizeof(dsa_pointer) * sa->chunk_array_allocated_size);
+ dsa_free(area, sa->chunks);
+ sa->chunks = new_chunks;
+ sa->chunk_array_allocated_size = new_size;
+ }
+ else
+ chunk_array = dsa_get_address(area, sa->chunks);
+
+ /* Get the address of the desired chunk, allocating it if needed. */
+ if (chunk_array[chunk_number] == InvalidDsaPointer)
+ chunk_array[chunk_number] =
+ dsa_allocate0(area, sizeof(pgpa_shared_advice_chunk));
+ chunk = dsa_get_address(area, chunk_array[chunk_number]);
+
+ /* Save pointer and bump next-id counter. */
+ Assert(chunk->entries[chunk_offset] == InvalidDsaPointer);
+ chunk->entries[chunk_offset] = ca_pointer;
+ ++sa->next_id;
+
+ /* If we've exceeded the storage limit, discard old data. */
+ pgpa_trim_shared_advice(area, pg_plan_advice_shared_collection_limit);
+
+ /* Release lock on shared state. */
+ LWLockRelease(&state->lock);
+}
+
+/*
+ * Discard collected advice stored in backend-local memory in excess of the
+ * specified limit.
+ */
+static void
+pgpa_trim_local_advice(int limit)
+{
+ pgpa_local_advice *la = local_collector;
+ uint64 current_count;
+ uint64 trim_count;
+ uint64 total_chunk_count;
+ uint64 trim_chunk_count;
+ uint64 remaining_chunk_count;
+
+ /* If we haven't yet reached the limit, there's nothing to do. */
+ current_count = la->next_id - la->oldest_id;
+ if (current_count <= limit)
+ return;
+
+ /* Free enough entries to get us back down to the limit. */
+ trim_count = current_count - limit;
+ while (trim_count > 0)
+ {
+ uint64 chunk_number;
+ uint64 chunk_offset;
+
+ chunk_number = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+ chunk_offset = (la->oldest_id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+ Assert(la->chunks[chunk_number]->entries[chunk_offset] != NULL);
+ pfree(la->chunks[chunk_number]->entries[chunk_offset]);
+ la->chunks[chunk_number]->entries[chunk_offset] = NULL;
+ ++la->oldest_id;
+ --trim_count;
+ }
+
+ /* Free any chunks that are now entirely unused. */
+ trim_chunk_count = (la->oldest_id - la->base_id) / ADVICE_CHUNK_SIZE;
+ for (uint64 n = 0; n < trim_chunk_count; ++n)
+ pfree(la->chunks[n]);
+
+ /* Slide remaining chunk pointers back toward the base of the array. */
+ total_chunk_count = (la->next_id - la->base_id +
+ ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+ remaining_chunk_count = total_chunk_count - trim_chunk_count;
+ if (remaining_chunk_count > 0)
+ memmove(&la->chunks[0], &la->chunks[trim_chunk_count],
+ sizeof(pgpa_local_advice_chunk *) * remaining_chunk_count);
+
+ /* Don't leave stale pointers around. */
+ memset(&la->chunks[remaining_chunk_count], 0,
+ sizeof(pgpa_local_advice_chunk *)
+ * (total_chunk_count - remaining_chunk_count));
+
+ /* Adjust base ID value accordingly. */
+ la->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * Discard collected advice stored in shared memory in excess of the
+ * specified limit.
+ */
+static void
+pgpa_trim_shared_advice(dsa_area *area, int limit)
+{
+ pgpa_shared_advice *sa = shared_collector;
+ uint64 current_count;
+ uint64 trim_count;
+ uint64 total_chunk_count;
+ uint64 trim_chunk_count;
+ uint64 remaining_chunk_count;
+ dsa_pointer *chunk_array;
+
+ /* If we haven't yet reached the limit, there's nothing to do. */
+ current_count = sa->next_id - sa->oldest_id;
+ if (current_count <= limit)
+ return;
+
+ /* Get a pointer to the chunk array. */
+ chunk_array = dsa_get_address(area, sa->chunks);
+
+ /* Free enough entries to get us back down to the limit. */
+ trim_count = current_count - limit;
+ while (trim_count > 0)
+ {
+ uint64 chunk_number;
+ uint64 chunk_offset;
+ pgpa_shared_advice_chunk *chunk;
+
+ chunk_number = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+ chunk_offset = (sa->oldest_id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+ chunk = dsa_get_address(area, chunk_array[chunk_number]);
+ Assert(chunk->entries[chunk_offset] != InvalidDsaPointer);
+ dsa_free(area, chunk->entries[chunk_offset]);
+ chunk->entries[chunk_offset] = InvalidDsaPointer;
+ ++sa->oldest_id;
+ --trim_count;
+ }
+
+ /* Free any chunks that are now entirely unused. */
+ trim_chunk_count = (sa->oldest_id - sa->base_id) / ADVICE_CHUNK_SIZE;
+ for (uint64 n = 0; n < trim_chunk_count; ++n)
+ dsa_free(area, chunk_array[n]);
+
+ /* Slide remaining chunk pointers back toward the base of the array. */
+ total_chunk_count = (sa->next_id - sa->base_id +
+ ADVICE_CHUNK_SIZE - 1) / ADVICE_CHUNK_SIZE;
+ remaining_chunk_count = total_chunk_count - trim_chunk_count;
+ if (remaining_chunk_count > 0)
+ memmove(&chunk_array[0], &chunk_array[trim_chunk_count],
+ sizeof(dsa_pointer) * remaining_chunk_count);
+
+ /* Don't leave stale pointers around. */
+ memset(&chunk_array[remaining_chunk_count], 0,
+ sizeof(pgpa_shared_advice_chunk *)
+ * (total_chunk_count - remaining_chunk_count));
+
+ /* Adjust base ID value accordingly. */
+ sa->base_id += trim_chunk_count * ADVICE_CHUNK_SIZE;
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_local_advice(PG_FUNCTION_ARGS)
+{
+ if (local_collector != NULL)
+ pgpa_trim_local_advice(0);
+
+ PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable function to discard advice collected in backend-local memory
+ */
+Datum
+pg_clear_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+ pgpa_shared_state *state = pg_plan_advice_attach();
+ dsa_area *area = pg_plan_advice_dsa_area();
+
+ LWLockAcquire(&state->lock, LW_EXCLUSIVE);
+
+ /*
+ * If we're not attached to the shared advice collector yet, fix that now;
+ * but if the collector doesn't even exist, we can return without doing
+ * anything else.
+ */
+ if (shared_collector == NULL)
+ {
+ if (state->shared_collector == InvalidDsaPointer)
+ {
+ LWLockRelease(&state->lock);
+ return (Datum) 0;
+ }
+ shared_collector = dsa_get_address(area, state->shared_collector);
+ }
+
+ /* Do the real work */
+ pgpa_trim_shared_advice(area, 0);
+
+ LWLockRelease(&state->lock);
+
+ PG_RETURN_VOID();
+}
+
+/*
+ * SQL-callable SRF to return advice collected in backend-local memory
+ */
+Datum
+pg_get_collected_local_advice(PG_FUNCTION_ARGS)
+{
+ ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+ pgpa_local_advice *la = local_collector;
+ Oid userid = GetUserId();
+
+ InitMaterializedSRF(fcinfo, 0);
+
+ if (la == NULL)
+ return (Datum) 0;
+
+ /* Loop over all entries. */
+ for (uint64 id = la->oldest_id; id < la->next_id; ++id)
+ {
+ uint64 chunk_number;
+ uint64 chunk_offset;
+ pgpa_collected_advice *ca;
+ Datum values[PG_GET_ADVICE_COLUMNS];
+ bool nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+ chunk_number = (id - la->base_id) / ADVICE_CHUNK_SIZE;
+ chunk_offset = (id - la->base_id) % ADVICE_CHUNK_SIZE;
+
+ ca = la->chunks[chunk_number]->entries[chunk_offset];
+
+ if (!member_can_set_role(userid, ca->userid))
+ continue;
+
+ values[0] = UInt64GetDatum(id);
+ values[1] = ObjectIdGetDatum(ca->userid);
+ values[2] = ObjectIdGetDatum(ca->dbid);
+ values[3] = UInt64GetDatum(ca->queryid);
+ values[4] = TimestampGetDatum(ca->timestamp);
+ values[5] = CStringGetTextDatum(query_string(ca));
+ values[6] = CStringGetTextDatum(advice_string(ca));
+
+ tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+ values, nulls);
+ }
+
+ return (Datum) 0;
+}
+
+/*
+ * SQL-callable SRF to return advice collected in shared memory
+ */
+Datum
+pg_get_collected_shared_advice(PG_FUNCTION_ARGS)
+{
+ ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+ pgpa_shared_state *state = pg_plan_advice_attach();
+ dsa_area *area = pg_plan_advice_dsa_area();
+ dsa_pointer *chunk_array;
+ pgpa_shared_advice *sa = shared_collector;
+
+ InitMaterializedSRF(fcinfo, 0);
+
+ /* Lock the shared state. */
+ LWLockAcquire(&state->lock, LW_SHARED);
+
+ /*
+ * If we're not attached to the shared advice collector yet, fix that now;
+ * but if the collector doesn't even exist, we can return without doing
+ * anything else.
+ */
+ if (sa == NULL)
+ {
+ if (state->shared_collector == InvalidDsaPointer)
+ {
+ LWLockRelease(&state->lock);
+ return (Datum) 0;
+ }
+ shared_collector = sa = dsa_get_address(area, state->shared_collector);
+ }
+
+ /* Get a pointer to the chunk array. */
+ chunk_array = dsa_get_address(area, sa->chunks);
+
+ /* Loop over all entries. */
+ for (uint64 id = sa->oldest_id; id < sa->next_id; ++id)
+ {
+ uint64 chunk_number;
+ uint64 chunk_offset;
+ pgpa_shared_advice_chunk *chunk;
+ pgpa_collected_advice *ca;
+ Datum values[PG_GET_ADVICE_COLUMNS];
+ bool nulls[PG_GET_ADVICE_COLUMNS] = {0};
+
+ chunk_number = (id - sa->base_id) / ADVICE_CHUNK_SIZE;
+ chunk_offset = (id - sa->base_id) % ADVICE_CHUNK_SIZE;
+
+ chunk = dsa_get_address(area, chunk_array[chunk_number]);
+ ca = dsa_get_address(area, chunk->entries[chunk_offset]);
+
+ values[0] = UInt64GetDatum(id);
+ values[1] = ObjectIdGetDatum(ca->userid);
+ values[2] = ObjectIdGetDatum(ca->dbid);
+ values[3] = UInt64GetDatum(ca->queryid);
+ values[4] = TimestampGetDatum(ca->timestamp);
+ values[5] = CStringGetTextDatum(query_string(ca));
+ values[6] = CStringGetTextDatum(advice_string(ca));
+
+ tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc,
+ values, nulls);
+ }
+
+ /* Release lock on shared state. */
+ LWLockRelease(&state->lock);
+
+ return (Datum) 0;
+}
--- /dev/null
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_collector.h
+ * collect advice into backend-local or shared memory
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_collector.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_COLLECTOR_H
+#define PGPA_COLLECTOR_H
+
+extern void pgpa_collect_advice(uint64 queryId, const char *query_string,
+ const char *advice_string);
+
+#endif
--- /dev/null
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_identifier.c
+ * create appropriate identifiers for range table entries
+ *
+ * The goal of this module is to be able to produce identifiers for range
+ * table entries that are unique, understandable to human beings, and
+ * able to be reconstructed during future planning cycles. As an
+ * exception, we do not care about, or want to produce, identifiers for
+ * RTE_JOIN entries. This is because (1) we would end up with a ton of
+ * RTEs with unhelpful names like unnamed_join_17; (2) not all joins have
+ * RTEs; and (3) we intend to refer to joins by their constituent members
+ * rather than by reference to the join RTE.
+ *
+ * In general, we construct identifiers of the following form:
+ *
+ * alias_name#occurrence_number/child_table_name@subquery_name
+ *
+ * However, occurrence_number is omitted when it is the first occurrence
+ * within the same subquery, child_table_name is omitted for relations that
+ * are not child tables, and subquery_name is omitted for the topmost
+ * query level. Whenever an item is omitted, the preceding punctuation mark
+ * is also omitted. Identifier-style escaping is applied to alias_name and
+ * subquery_name. Whenever we include child_table_name, we always
+ * schema-qualified name, but writing their own plan advice are not required
+ * to do so. Identifier-style escaping is applied to the schema and to the
+ * relation names separately.
+ *
+ * The upshot of all of these rules is that in simple cases, the relation
+ * identifier is textually identical to the alias name, making life easier
+ * for users. However, even in complex cases, every relation identifier
+ * for a given query will be unique (or at least we hope so: if not, this
+ * code is buggy and the identifier format might need to be rethought).
+ *
+ * A key goal of this system is that we want to be able to reconstruct the
+ * same identifiers during a future planning cycle for the same query, so
+ * that if a certain behavior is specified for a certain identifier, we can
+ * properly identify the RTI for which that behavior is mandated. In order
+ * for this to work, subquery names must be unique and known before the
+ * subquery is planned, and the remainder of the identifier must not depend
+ * on any part of the query outside of the current subquery level. In
+ * particular, occurrence_number must be calculated relative to the range
+ * table for the relevant subquery, not the final flattened range table.
+ *
+ * Copyright (c) 2016-2024, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_identifier.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_identifier.h"
+
+#include "parser/parsetree.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+
+static Index *pgpa_create_top_rti_map(Index rtable_length, List *rtable,
+ List *appinfos);
+static int pgpa_occurrence_number(List *rtable, Index *top_rti_map,
+ SubPlanRTInfo *rtinfo, Index rti);
+
+/*
+ * Create a range table identifier from scratch.
+ *
+ * This function leaves the caller to do all the heavy lifting, so it's
+ * generally better to use one of the functions below instead.
+ *
+ * See the file header comments for more details on the format of an
+ * identifier.
+ */
+const char *
+pgpa_identifier_string(const pgpa_identifier *rid)
+{
+ const char *result;
+
+ Assert(rid->alias_name != NULL);
+ result = quote_identifier(rid->alias_name);
+
+ Assert(rid->occurrence >= 0);
+ if (rid->occurrence > 1)
+ result = psprintf("%s#%d", result, rid->occurrence);
+
+ if (rid->partrel != NULL)
+ {
+ if (rid->partnsp == NULL)
+ result = psprintf("%s/%s", result,
+ quote_identifier(rid->partrel));
+ else
+ result = psprintf("%s/%s.%s", result,
+ quote_identifier(rid->partnsp),
+ quote_identifier(rid->partrel));
+ }
+
+ if (rid->plan_name != NULL)
+ result = psprintf("%s@%s", result, quote_identifier(rid->plan_name));
+
+ return result;
+}
+
+/*
+ * Compute a relation identifier for a particular RTI.
+ *
+ * The caller provides root and rti, and gets the necessary details back via
+ * the remaining parameters.
+ */
+void
+pgpa_compute_identifier_by_rti(PlannerInfo *root, Index rti,
+ pgpa_identifier *rid)
+{
+ Index top_rti = rti;
+ int occurrence = 1;
+ RangeTblEntry *rte;
+ RangeTblEntry *top_rte;
+ char *partnsp = NULL;
+ char *partrel = NULL;
+
+ /*
+ * If this is a child RTE, find the topmost parent that is still of type
+ * RTE_RELATION. We do this because we identify children of partitioned
+ * tables by the name of the child table, but subqueries can also have
+ * child rels and we don't care about those here.
+ */
+ for (;;)
+ {
+ AppendRelInfo *appinfo;
+ RangeTblEntry *parent_rte;
+
+ /* append_rel_array can be NULL if there are no children */
+ if (root->append_rel_array == NULL ||
+ (appinfo = root->append_rel_array[top_rti]) == NULL)
+ break;
+
+ parent_rte = planner_rt_fetch(appinfo->parent_relid, root);
+ if (parent_rte->rtekind != RTE_RELATION)
+ break;
+
+ top_rti = appinfo->parent_relid;
+ }
+
+ /* Get the range table entries for the RTI and top RTI. */
+ rte = planner_rt_fetch(rti, root);
+ top_rte = planner_rt_fetch(top_rti, root);
+ Assert(rte->rtekind != RTE_JOIN);
+ Assert(top_rte->rtekind != RTE_JOIN);
+
+ /* Work out the correct occurrence number. */
+ for (Index prior_rti = 1; prior_rti < top_rti; ++prior_rti)
+ {
+ RangeTblEntry *prior_rte;
+ AppendRelInfo *appinfo;
+
+ /*
+ * If this is a child rel of a parent that is a relation, skip it.
+ *
+ * Such range table entries are disambiguated by mentioning the schema
+ * and name of the table, not by counting them as separate occurrences
+ * of the same table.
+ *
+ * NB: append_rel_array can be NULL if there are no children
+ */
+ if (root->append_rel_array != NULL &&
+ (appinfo = root->append_rel_array[prior_rti]) != NULL)
+ {
+ RangeTblEntry *parent_rte;
+
+ parent_rte = planner_rt_fetch(appinfo->parent_relid, root);
+ if (parent_rte->rtekind == RTE_RELATION)
+ continue;
+ }
+
+ /* Skip NULL entries and joins. */
+ prior_rte = planner_rt_fetch(prior_rti, root);
+ if (prior_rte == NULL || prior_rte->rtekind == RTE_JOIN)
+ continue;
+
+ /* Skip if the alias name differs. */
+ if (strcmp(prior_rte->eref->aliasname, rte->eref->aliasname) != 0)
+ continue;
+
+ /* Looks like a true duplicate. */
+ ++occurrence;
+ }
+
+ /* If this is a child table, get the schema and relation names. */
+ if (rti != top_rti)
+ {
+ partnsp = get_namespace_name_or_temp(get_rel_namespace(rte->relid));
+ partrel = get_rel_name(rte->relid);
+ }
+
+ /* OK, we have all the answers we need. Return them to the caller. */
+ rid->alias_name = top_rte->eref->aliasname;
+ rid->occurrence = occurrence;
+ rid->partnsp = partnsp;
+ rid->partrel = partrel;
+ rid->plan_name = root->plan_name;
+}
+
+/*
+ * Compute a relation identifier for a set of RTIs, except for any RTE_JOIN
+ * RTIs that may be present.
+ *
+ * RTE_JOIN entries are excluded because they cannot be mentioned by plan
+ * advice.
+ *
+ * The caller is responsible for making sure that the tkeys array is large
+ * enough to store the results.
+ *
+ * The return value is the number of identifiers computed.
+ */
+int
+pgpa_compute_identifiers_by_relids(PlannerInfo *root, Bitmapset *relids,
+ pgpa_identifier *rids)
+{
+ int count = 0;
+ int rti = -1;
+
+ while ((rti = bms_next_member(relids, rti)) >= 0)
+ {
+ RangeTblEntry *rte = planner_rt_fetch(rti, root);
+
+ if (rte->rtekind == RTE_JOIN)
+ continue;
+ pgpa_compute_identifier_by_rti(root, rti, &rids[count++]);
+ }
+
+ Assert(count > 0);
+ return count;
+}
+
+/*
+ * Create an array of range table identifiers for all the non-NULL,
+ * non-RTE_JOIN entries in the PlannedStmt's range table.
+ */
+pgpa_identifier *
+pgpa_create_identifiers_for_planned_stmt(PlannedStmt *pstmt)
+{
+ Index rtable_length = list_length(pstmt->rtable);
+ pgpa_identifier *result = palloc0_array(pgpa_identifier, rtable_length);
+ Index *top_rti_map;
+ int rtinfoindex = 0;
+ SubPlanRTInfo *rtinfo = NULL;
+ SubPlanRTInfo *nextrtinfo = NULL;
+
+ /*
+ * Account for relations addded by inheritance expansion of partitioned
+ * tables.
+ */
+ top_rti_map = pgpa_create_top_rti_map(rtable_length, pstmt->rtable,
+ pstmt->appendRelations);
+
+ /*
+ * When we begin iterating, we're processing the portion of the range
+ * table that originated from the top-level PlannerInfo, so subrtinfo is
+ * NULL. Later, subrtinfo will be the SubPlanRTInfo for the subquery whose
+ * portion of the range table we are processing. nextrtinfo is always the
+ * SubPlanRTInfo that follows the current one, if any, so when we're
+ * processing the top-level query's portion of the range table, the next
+ * SubPlanRTInfo is the very first one.
+ */
+ if (pstmt->subrtinfos != NULL)
+ nextrtinfo = linitial(pstmt->subrtinfos);
+
+ /* Main loop over the range table. */
+ for (Index rti = 1; rti <= rtable_length; rti++)
+ {
+ const char *plan_name;
+ Index top_rti;
+ RangeTblEntry *rte;
+ RangeTblEntry *top_rte;
+ char *partnsp = NULL;
+ char *partrel = NULL;
+ int occurrence;
+ pgpa_identifier *rid;
+
+ /*
+ * Advance to the next SubPlanRTInfo, if it's time to do that.
+ *
+ * This loop probably shouldn't ever iterate more than once, because
+ * that would imply that a subquery was planned but added nothing to
+ * the range table; but let's be defensive and assume it can happen.
+ */
+ while (nextrtinfo != NULL && rti > nextrtinfo->rtoffset)
+ {
+ rtinfo = nextrtinfo;
+ if (++rtinfoindex >= list_length(pstmt->subrtinfos))
+ nextrtinfo = NULL;
+ else
+ nextrtinfo = list_nth(pstmt->subrtinfos, rtinfoindex);
+ }
+
+ /* Fetch the range table entry, if any. */
+ rte = rt_fetch(rti, pstmt->rtable);
+
+ /*
+ * We can't and don't need to identify null entries, and we don't want
+ * to identify join entries.
+ */
+ if (rte == NULL || rte->rtekind == RTE_JOIN)
+ continue;
+
+ /*
+ * If this is not a relation added by partitioned table expansion,
+ * then the top RTI/RTE are just the same as this RTI/RTE. Otherwise,
+ * we need the information for the top RTI/RTE, and must also fetch
+ * the partition schema and name.
+ */
+ top_rti = top_rti_map[rti - 1];
+ if (rti == top_rti)
+ top_rte = rte;
+ else
+ {
+ top_rte = rt_fetch(top_rti, pstmt->rtable);
+ partnsp =
+ get_namespace_name_or_temp(get_rel_namespace(rte->relid));
+ partrel = get_rel_name(rte->relid);
+ }
+
+ /* Compute the correct occurrence number. */
+ occurrence = pgpa_occurrence_number(pstmt->rtable, top_rti_map,
+ rtinfo, top_rti);
+
+ /* Get the name of the current plan (NULL for toplevel query). */
+ plan_name = rtinfo == NULL ? NULL : rtinfo->plan_name;
+
+ /* Save all the details we've derived. */
+ rid = &result[rti - 1];
+ rid->alias_name = top_rte->eref->aliasname;
+ rid->occurrence = occurrence;
+ rid->partnsp = partnsp;
+ rid->partrel = partrel;
+ rid->plan_name = plan_name;
+ }
+
+ return result;
+}
+
+/*
+ * Search for a pgpa_identifier in the array of identifiers computed for the
+ * range table. If exactly one match is found, return the matching RTI; else
+ * return 0.
+ */
+Index
+pgpa_compute_rti_from_identifier(int rtable_length,
+ pgpa_identifier *rt_identifiers,
+ pgpa_identifier *rid)
+{
+ Index result = 0;
+
+ for (Index rti = 1; rti <= rtable_length; ++rti)
+ {
+ pgpa_identifier *rti_rid = &rt_identifiers[rti - 1];
+
+ /* If there's no identifier for this RTI, skip it. */
+ if (rti_rid->alias_name == NULL)
+ continue;
+
+ /*
+ * If it matches, return this RTI. As usual, an omitted partition
+ * schema matches anything, but partition and plan names must either
+ * match exactly or be omitted on both sides.
+ */
+ if (strcmp(rid->alias_name, rti_rid->alias_name) == 0 &&
+ rid->occurrence == rti_rid->occurrence &&
+ (rid->partnsp == NULL || rti_rid->partnsp == NULL ||
+ strcmp(rid->partnsp, rti_rid->partnsp) == 0) &&
+ strings_equal_or_both_null(rid->partrel, rti_rid->partrel) &&
+ strings_equal_or_both_null(rid->plan_name, rti_rid->plan_name))
+ {
+ if (result != 0)
+ {
+ /* Multiple matches were found. */
+ return 0;
+ }
+ result = rti;
+ }
+ }
+
+ return result;
+}
+
+/*
+ * Build a mapping from each RTI to the RTI whose alias_name will be used to
+ * construct the range table identifier.
+ *
+ * For child relations, this is the topmost parent that is still of type
+ * RTE_RELATION. For other relations, it's just the original RTI.
+ *
+ * Since we're eventually going to need this information for every RTI in
+ * the range table, it's best to compute all the answers in a single pass over
+ * the AppendRelInfo list. Otherwise, we might end up searching through that
+ * list repeatedly for entries of interest.
+ *
+ * Note that the returned array is uses zero-based indexing, while RTIs use
+ * 1-based indexing, so subtract 1 from the RTI before looking it up in the
+ * array.
+ */
+static Index *
+pgpa_create_top_rti_map(Index rtable_length, List *rtable, List *appinfos)
+{
+ Index *top_rti_map = palloc0_array(Index, rtable_length);
+
+ /* Initially, make every RTI point to itself. */
+ for (Index rti = 1; rti <= rtable_length; ++rti)
+ top_rti_map[rti - 1] = rti;
+
+ /* Update the map for each AppendRelInfo object. */
+ foreach_node(AppendRelInfo, appinfo, appinfos)
+ {
+ Index parent_rti = appinfo->parent_relid;
+ RangeTblEntry *parent_rte = rt_fetch(parent_rti, rtable);
+
+ /* If the parent is not RTE_RELATION, ignore this entry. */
+ if (parent_rte->rtekind != RTE_RELATION)
+ continue;
+
+ /*
+ * Map the child to wherever we mapped the parent. Parents always
+ * precede their children in the AppendRelInfo list, so this should
+ * work out.
+ */
+ top_rti_map[appinfo->child_relid - 1] = top_rti_map[parent_rti - 1];
+ }
+
+ return top_rti_map;
+}
+
+/*
+ * Find the occurence number of a certain relation within a certain subquery.
+ *
+ * The same alias name can occur multiple times within a subquery, but we want
+ * to disambiguate by giving different occurrences different integer indexes.
+ * However, child tables are disambiguated by including the table name rather
+ * than by incrementing the occurrence number; and joins are not named and so
+ * shouldn't increment the occurence number either.
+ */
+static int
+pgpa_occurrence_number(List *rtable, Index *top_rti_map,
+ SubPlanRTInfo *rtinfo, Index rti)
+{
+ Index rtoffset = (rtinfo == NULL) ? 0 : rtinfo->rtoffset;
+ int occurrence = 1;
+ RangeTblEntry *rte = rt_fetch(rti, rtable);
+
+ for (Index prior_rti = rtoffset + 1; prior_rti < rti; ++prior_rti)
+ {
+ RangeTblEntry *prior_rte;
+
+ /*
+ * If this is a child rel of a parent that is a relation, skip it.
+ *
+ * Such range table entries are disambiguated by mentioning the schema
+ * and name of the table, not by counting them as separate occurrences
+ * of the same table.
+ */
+ if (top_rti_map[prior_rti - 1] != prior_rti)
+ break;
+
+ /* Skip joins. */
+ prior_rte = rt_fetch(prior_rti, rtable);
+ if (prior_rte->rtekind == RTE_JOIN)
+ continue;
+
+ /* Skip if the alias name differs. */
+ if (strcmp(prior_rte->eref->aliasname, rte->eref->aliasname) != 0)
+ continue;
+
+ /* Looks like a true duplicate. */
+ ++occurrence;
+ }
+
+ return occurrence;
+}
--- /dev/null
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_identifier.h
+ * create appropriate identifiers for range table entries
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_identifier.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef PGPA_IDENTIFIER_H
+#define PGPA_IDENTIFIER_H
+
+#include "nodes/pathnodes.h"
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_identifier
+{
+ const char *alias_name;
+ int occurrence;
+ const char *partnsp;
+ const char *partrel;
+ const char *plan_name;
+} pgpa_identifier;
+
+/* Convenience function for comparing possibly-NULL strings. */
+static inline bool
+strings_equal_or_both_null(const char *a, const char *b)
+{
+ if (a == b)
+ return true;
+ else if (a == NULL || b == NULL)
+ return false;
+ else
+ return strcmp(a, b) == 0;
+}
+
+extern const char *pgpa_identifier_string(const pgpa_identifier *rid);
+extern void pgpa_compute_identifier_by_rti(PlannerInfo *root, Index rti,
+ pgpa_identifier *rid);
+extern int pgpa_compute_identifiers_by_relids(PlannerInfo *root,
+ Bitmapset *relids,
+ pgpa_identifier *rids);
+extern pgpa_identifier *pgpa_create_identifiers_for_planned_stmt(PlannedStmt *pstmt);
+
+extern Index pgpa_compute_rti_from_identifier(int rtable_length,
+ pgpa_identifier *rt_identifiers,
+ pgpa_identifier *rid);
+
+#endif
--- /dev/null
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_join.c
+ * analysis of joins in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_join.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_join.h"
+#include "pgpa_scan.h"
+#include "pgpa_walker.h"
+
+#include "nodes/pathnodes.h"
+#include "nodes/print.h"
+#include "parser/parsetree.h"
+
+/*
+ * Temporary object used when unrolling a join tree.
+ */
+struct pgpa_join_unroller
+{
+ unsigned nallocated;
+ unsigned nused;
+ Plan *outer_subplan;
+ ElidedNode *outer_elided_node;
+ bool outer_beneath_any_gather;
+ pgpa_join_strategy *strategy;
+ Plan **inner_subplans;
+ ElidedNode **inner_elided_nodes;
+ pgpa_join_unroller **inner_unrollers;
+ bool *inner_beneath_any_gather;
+};
+
+static pgpa_join_strategy pgpa_decompose_join(pgpa_plan_walker_context *walker,
+ Plan *plan,
+ Plan **realouter,
+ Plan **realinner,
+ ElidedNode **elidedrealouter,
+ ElidedNode **elidedrealinner,
+ bool *found_any_outer_gather,
+ bool *found_any_inner_gather);
+static ElidedNode *pgpa_descend_node(PlannedStmt *pstmt, Plan **plan);
+static ElidedNode *pgpa_descend_any_gather(PlannedStmt *pstmt, Plan **plan,
+ bool *found_any_gather);
+static bool pgpa_descend_any_unique(PlannedStmt *pstmt, Plan **plan,
+ ElidedNode **elided_node);
+
+static bool is_result_node_with_child(Plan *plan);
+static bool is_sorting_plan(Plan *plan);
+
+/*
+ * Create an initially-empty object for unrolling joins.
+ *
+ * This function creates a helper object that can later be used to create a
+ * pgpa_unrolled_join, after first calling pgpa_unroll_join one or more times.
+ */
+pgpa_join_unroller *
+pgpa_create_join_unroller(void)
+{
+ pgpa_join_unroller *join_unroller;
+
+ join_unroller = palloc0_object(pgpa_join_unroller);
+ join_unroller->nallocated = 4;
+ join_unroller->strategy =
+ palloc_array(pgpa_join_strategy, join_unroller->nallocated);
+ join_unroller->inner_subplans =
+ palloc_array(Plan *, join_unroller->nallocated);
+ join_unroller->inner_elided_nodes =
+ palloc_array(ElidedNode *, join_unroller->nallocated);
+ join_unroller->inner_unrollers =
+ palloc_array(pgpa_join_unroller *, join_unroller->nallocated);
+ join_unroller->inner_beneath_any_gather =
+ palloc_array(bool, join_unroller->nallocated);
+
+ return join_unroller;
+}
+
+/*
+ * Unroll one level of an unrollable join tree.
+ *
+ * Our basic goal here is to unroll join trees as they occur in the Plan
+ * tree into a simpler and more regular structure that we can more easily
+ * use for further processing. Unrolling is outer-deep, so if the plan tree
+ * has Join1(Join2(A,B),Join3(C,D)), the same join unroller object should be
+ * used for Join1 and Join2, but a different one will be needed for Join3,
+ * since that involves a join within the *inner* side of another join.
+ *
+ * pgpa_plan_walker creates a "top level" join unroller object when it
+ * encounters a join in a portion of the plan tree in which no join unroller
+ * is already active. From there, this function is responsible for determing
+ * to what portion of the plan tree that join unroller applies, and for
+ * creating any subordinate join unroller objects that are needed as a result
+ * of non-outer-deep join trees. We do this by returning the join unroller
+ * objects that should be used for further traversal of the outer and inner
+ * subtrees of the current plan node via *outer_join_unroller and
+ * *inner_join_unroller, respectively.
+ */
+void
+pgpa_unroll_join(pgpa_plan_walker_context *walker, Plan *plan,
+ bool beneath_any_gather,
+ pgpa_join_unroller *join_unroller,
+ pgpa_join_unroller **outer_join_unroller,
+ pgpa_join_unroller **inner_join_unroller)
+{
+ pgpa_join_strategy strategy;
+ Plan *realinner,
+ *realouter;
+ ElidedNode *elidedinner,
+ *elidedouter;
+ int n;
+ bool found_any_outer_gather = false;
+ bool found_any_inner_gather = false;
+
+ Assert(join_unroller != NULL);
+
+ /*
+ * We need to pass the join_unroller object down through certain types of
+ * plan nodes -- anything that's considered part of the join strategy, and
+ * any other nodes that can occur in a join tree despite not being scans
+ * or joins.
+ *
+ * This includes:
+ *
+ * (1) Materialize, Memoize, and Hash nodes, which are part of the join
+ * strategy,
+ *
+ * (2) Gather and Gather Merge nodes, which can occur at any point in the
+ * join tree where the planner decided to initiate parallelism,
+ *
+ * (3) Sort and IncrementalSort nodes, which can occur beneath MergeJoin
+ * or GatherMerge,
+ *
+ * (4) Agg and Unique nodes, which can occur when we decide to make the
+ * nullable side of a semijoin unique and then join the result, and
+ *
+ * (5) Result nodes with children, which can be added either to project to
+ * enforce a one-time filter (but Result nodes without children are
+ * degenerate scans or joins).
+ */
+ if (IsA(plan, Material) || IsA(plan, Memoize) || IsA(plan, Hash)
+ || IsA(plan, Gather) || IsA(plan, GatherMerge)
+ || is_sorting_plan(plan) || IsA(plan, Agg) || IsA(plan, Unique)
+ || is_result_node_with_child(plan))
+ {
+ *outer_join_unroller = join_unroller;
+ return;
+ }
+
+ /*
+ * Since we've already handled nodes that require pass-through treatment,
+ * this should be an unrollable join.
+ */
+ strategy = pgpa_decompose_join(walker, plan,
+ &realouter, &realinner,
+ &elidedouter, &elidedinner,
+ &found_any_outer_gather,
+ &found_any_inner_gather);
+
+ /* If our workspace is full, expand it. */
+ if (join_unroller->nused >= join_unroller->nallocated)
+ {
+ join_unroller->nallocated *= 2;
+ join_unroller->strategy =
+ repalloc_array(join_unroller->strategy,
+ pgpa_join_strategy,
+ join_unroller->nallocated);
+ join_unroller->inner_subplans =
+ repalloc_array(join_unroller->inner_subplans,
+ Plan *,
+ join_unroller->nallocated);
+ join_unroller->inner_elided_nodes =
+ repalloc_array(join_unroller->inner_elided_nodes,
+ ElidedNode *,
+ join_unroller->nallocated);
+ join_unroller->inner_beneath_any_gather =
+ repalloc_array(join_unroller->inner_beneath_any_gather,
+ bool,
+ join_unroller->nallocated);
+ join_unroller->inner_unrollers =
+ repalloc_array(join_unroller->inner_unrollers,
+ pgpa_join_unroller *,
+ join_unroller->nallocated);
+ }
+
+ /*
+ * Since we're flattening outer-deep join trees, it follows that if the
+ * outer side is still an unrollable join, it should be unrolled into this
+ * same object. Otherwise, we've reached the limit of what we can unroll
+ * into this object and must remember the outer side as the final outer
+ * subplan.
+ */
+ if (elidedouter == NULL && pgpa_is_join(realouter))
+ *outer_join_unroller = join_unroller;
+ else
+ {
+ join_unroller->outer_subplan = realouter;
+ join_unroller->outer_elided_node = elidedouter;
+ join_unroller->outer_beneath_any_gather =
+ beneath_any_gather || found_any_outer_gather;
+ }
+
+ /*
+ * Store the inner subplan. If it's an unrollable join, it needs to be
+ * flattened in turn, but into a new unroller object, not this one.
+ */
+ n = join_unroller->nused++;
+ join_unroller->strategy[n] = strategy;
+ join_unroller->inner_subplans[n] = realinner;
+ join_unroller->inner_elided_nodes[n] = elidedinner;
+ join_unroller->inner_beneath_any_gather[n] =
+ beneath_any_gather || found_any_inner_gather;
+ if (elidedinner == NULL && pgpa_is_join(realinner))
+ *inner_join_unroller = pgpa_create_join_unroller();
+ else
+ *inner_join_unroller = NULL;
+ join_unroller->inner_unrollers[n] = *inner_join_unroller;
+}
+
+/*
+ * Use the data we've accumulated in a pgpa_join_unroller object to construct
+ * a pgpa_unrolled_join.
+ */
+pgpa_unrolled_join *
+pgpa_build_unrolled_join(pgpa_plan_walker_context *walker,
+ pgpa_join_unroller *join_unroller)
+{
+ pgpa_unrolled_join *ujoin;
+ int i;
+
+ /*
+ * We shouldn't have gone even so far as to create a join unroller unless
+ * we found at least one unrollable join.
+ */
+ Assert(join_unroller->nused > 0);
+
+ /* Allocate result structures. */
+ ujoin = palloc0_object(pgpa_unrolled_join);
+ ujoin->ninner = join_unroller->nused;
+ ujoin->strategy = palloc0_array(pgpa_join_strategy, join_unroller->nused);
+ ujoin->inner = palloc0_array(pgpa_join_member, join_unroller->nused);
+
+ /* Handle the outermost join. */
+ ujoin->outer.plan = join_unroller->outer_subplan;
+ ujoin->outer.elided_node = join_unroller->outer_elided_node;
+ ujoin->outer.scan =
+ pgpa_build_scan(walker, ujoin->outer.plan,
+ ujoin->outer.elided_node,
+ join_unroller->outer_beneath_any_gather,
+ true);
+
+ /*
+ * We want the joins from the deepest part of the plan tree to appear
+ * first in the result object, but the join unroller adds them in exactly
+ * the reverse of that order, so we need to flip the order of the arrays
+ * when constructing the final result.
+ */
+ for (i = 0; i < join_unroller->nused; ++i)
+ {
+ int k = join_unroller->nused - i - 1;
+
+ /* Copy strategy, Plan, and ElidedNode. */
+ ujoin->strategy[i] = join_unroller->strategy[k];
+ ujoin->inner[i].plan = join_unroller->inner_subplans[k];
+ ujoin->inner[i].elided_node = join_unroller->inner_elided_nodes[k];
+
+ /*
+ * Fill in remaining details, using either the nested join unroller,
+ * or by deriving them from the plan and elided nodes.
+ */
+ if (join_unroller->inner_unrollers[k] != NULL)
+ ujoin->inner[i].unrolled_join =
+ pgpa_build_unrolled_join(walker,
+ join_unroller->inner_unrollers[k]);
+ else
+ ujoin->inner[i].scan =
+ pgpa_build_scan(walker, ujoin->inner[i].plan,
+ ujoin->inner[i].elided_node,
+ join_unroller->inner_beneath_any_gather[i],
+ true);
+ }
+
+ return ujoin;
+}
+
+/*
+ * Free memory allocated for pgpa_join_unroller.
+ */
+void
+pgpa_destroy_join_unroller(pgpa_join_unroller *join_unroller)
+{
+ pfree(join_unroller->strategy);
+ pfree(join_unroller->inner_subplans);
+ pfree(join_unroller->inner_elided_nodes);
+ pfree(join_unroller->inner_unrollers);
+ pfree(join_unroller);
+}
+
+/*
+ * Identify the join strategy used by a join and the "real" inner and outer
+ * plans.
+ *
+ * For example, a Hash Join always has a Hash node on the inner side, but
+ * for all intents and purposes the real inner input is the Hash node's child,
+ * not the Hash node itself.
+ *
+ * Likewise, a Merge Join may have Sort note on the inner or outer side; if
+ * it does, the real input to the join is the Sort node's child, not the
+ * Sort node itself.
+ *
+ * In addition, with a Merge Join or a Nested Loop, the join planning code
+ * may add additional nodes such as Materialize or Memoize. We regard these
+ * as an aspect of the join strategy. As in the previous cases, the true input
+ * to the join is the underlying node.
+ *
+ * However, if any involved child node previously had a now-elided node stacked
+ * on top, then we can't "look through" that node -- indeed, what's going to be
+ * relevant for our purposes is the ElidedNode on top of that plan node, rather
+ * than the plan node itself.
+ *
+ * If there are multiple elided nodes, we want that one that would have been
+ * uppermost in the plan tree prior to setrefs processing; we expect to find
+ * that one last in the list of elided nodes.
+ *
+ * On return *realouter and *realinner will have been set to the real inner
+ * and real outer plans that we identified, and *elidedrealouter and
+ * *elidedrealinner to the last of any correspoding elided nodes.
+ * Additionally, *found_any_outer_gather and *found_any_inner_gather will
+ * be set to true if we looked through a Gather or Gather Merge node on
+ * that side of the join, and false otherwise.
+ */
+static pgpa_join_strategy
+pgpa_decompose_join(pgpa_plan_walker_context *walker, Plan *plan,
+ Plan **realouter, Plan **realinner,
+ ElidedNode **elidedrealouter, ElidedNode **elidedrealinner,
+ bool *found_any_outer_gather, bool *found_any_inner_gather)
+{
+ PlannedStmt *pstmt = walker->pstmt;
+ JoinType jointype = ((Join *) plan)->jointype;
+ Plan *outerplan = plan->lefttree;
+ Plan *innerplan = plan->righttree;
+ ElidedNode *elidedouter;
+ ElidedNode *elidedinner;
+ pgpa_join_strategy strategy;
+ bool uniqueouter;
+ bool uniqueinner;
+
+ elidedouter = pgpa_last_elided_node(pstmt, outerplan);
+ elidedinner = pgpa_last_elided_node(pstmt, innerplan);
+ *found_any_outer_gather = false;
+ *found_any_inner_gather = false;
+
+ switch (nodeTag(plan))
+ {
+ case T_MergeJoin:
+
+ /*
+ * The planner may have chosen to place a Material node on the
+ * inner side of the MergeJoin; if this is present, we record it
+ * as part of the join strategy.
+ */
+ if (elidedinner == NULL && IsA(innerplan, Material))
+ {
+ elidedinner = pgpa_descend_node(pstmt, &innerplan);
+ strategy = JSTRAT_MERGE_JOIN_MATERIALIZE;
+ }
+ else
+ strategy = JSTRAT_MERGE_JOIN_PLAIN;
+
+ /*
+ * For a MergeJoin, either the outer or the inner subplan, or
+ * both, may have needed to be sorted; we must disregard any Sort
+ * or IncrementalSort node to find the real inner or outer
+ * subplan.
+ */
+ if (elidedouter == NULL && is_sorting_plan(outerplan))
+ elidedouter = pgpa_descend_node(pstmt, &outerplan);
+ if (elidedinner == NULL && is_sorting_plan(innerplan))
+ elidedinner = pgpa_descend_node(pstmt, &innerplan);
+ break;
+
+ case T_NestLoop:
+
+ /*
+ * The planner may have chosen to place a Material or Memoize node
+ * on the inner side of the NestLoop; if this is present, we
+ * record it as part of the join strategy.
+ */
+ if (elidedinner == NULL && IsA(innerplan, Material))
+ {
+ elidedinner = pgpa_descend_node(pstmt, &innerplan);
+ strategy = JSTRAT_NESTED_LOOP_MATERIALIZE;
+ }
+ else if (elidedinner == NULL && IsA(innerplan, Memoize))
+ {
+ elidedinner = pgpa_descend_node(pstmt, &innerplan);
+ strategy = JSTRAT_NESTED_LOOP_MEMOIZE;
+ }
+ else
+ strategy = JSTRAT_NESTED_LOOP_PLAIN;
+ break;
+
+ case T_HashJoin:
+
+ /*
+ * The inner subplan of a HashJoin is always a Hash node; the real
+ * inner subplan is the Hash node's child.
+ */
+ Assert(IsA(innerplan, Hash));
+ Assert(elidedinner == NULL);
+ elidedinner = pgpa_descend_node(pstmt, &innerplan);
+ strategy = JSTRAT_HASH_JOIN;
+ break;
+
+ default:
+ elog(ERROR, "unrecognized node type: %d", (int) nodeTag(plan));
+ }
+
+ /*
+ * The planner may have decided to implement a semijoin by first making
+ * the nullable side of the plan unique, and then performing a normal join
+ * against the result. Therefore, we might need to descend through a
+ * unique node on either side of the plan.
+ */
+ uniqueouter = pgpa_descend_any_unique(pstmt, &outerplan, &elidedouter);
+ uniqueinner = pgpa_descend_any_unique(pstmt, &innerplan, &elidedinner);
+
+ /*
+ * The planner may have decided to parallelize part of the join tree, so
+ * we could find a Gather or Gather Merge node here. Note that, if
+ * present, this will appear below nodes we considered as part of the join
+ * strategy, but we could find another uniqueness-enforcing node below the
+ * Gather or Gather Merge, if present.
+ */
+ if (elidedouter == NULL)
+ {
+ elidedouter = pgpa_descend_any_gather(pstmt, &outerplan,
+ found_any_outer_gather);
+ if (found_any_outer_gather &&
+ pgpa_descend_any_unique(pstmt, &outerplan, &elidedouter))
+ uniqueouter = true;
+ }
+ if (elidedinner == NULL)
+ {
+ elidedinner = pgpa_descend_any_gather(pstmt, &innerplan,
+ found_any_inner_gather);
+ if (found_any_inner_gather &&
+ pgpa_descend_any_unique(pstmt, &innerplan, &elidedinner))
+ uniqueinner = true;
+ }
+
+ /*
+ * It's possible that Result node has been inserted either to project a
+ * target list or to implement a one-time filter. If so, we can descend
+ * throught it. Note that a result node without a child would be a
+ * degenerate scan or join, and not something we could descend through.
+ *
+ * XXX. I suspect it's possible for this to happen above the Gather or
+ * Gather Merge node, too, but apparently we have no test case for that
+ * scenario.
+ */
+ if (elidedouter == NULL && is_result_node_with_child(outerplan))
+ elidedouter = pgpa_descend_node(pstmt, &outerplan);
+ if (elidedinner == NULL && is_result_node_with_child(innerplan))
+ elidedinner = pgpa_descend_node(pstmt, &innerplan);
+
+ /*
+ * If this is a semijoin that was converted to an inner join by making one
+ * side or the other unique, make a note that the inner or outer subplan,
+ * as appropriate, should be treated as a query plan feature when the main
+ * tree traversal reaches it.
+ *
+ * Conversely, if the planner could have made one side of the join unique
+ * and thereby converted it to an inner join, and chose not to do so, that
+ * is also worth noting.
+ *
+ * XXX: We admit too much non-unique advice, as in the following example
+ * from the regression tests: EXPLAIN (PLAN_ADVICE, COSTS OFF) DELETE FROM
+ * prt1_l WHERE EXISTS (SELECT 1 FROM int4_tbl, LATERAL (SELECT
+ * int4_tbl.f1 FROM int8_tbl LIMIT 2) ss WHERE prt1_l.c IS NULL). We emit
+ * SEMIJOIN_NON_UNIQUE((int4_tbl ss)) but create_unique_path() fails in
+ * this case, so there's no sj-unique version possible.
+ *
+ * NB: This code could appear slightly higher up in in this function, but
+ * none of the nodes through which we just descended should have
+ * associated RTIs.
+ *
+ * NB: This seems like a somewhat hacky way of passing information up to
+ * the main tree walk, but I don't currently have a better idea.
+ */
+ if (uniqueouter)
+ pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_UNIQUE, outerplan);
+ else if (jointype == JOIN_RIGHT_SEMI)
+ pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_NON_UNIQUE, outerplan);
+ if (uniqueinner)
+ pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_UNIQUE, innerplan);
+ else if (jointype == JOIN_SEMI)
+ pgpa_add_future_feature(walker, PGPAQF_SEMIJOIN_NON_UNIQUE, innerplan);
+
+ /* Set output parameters. */
+ *realouter = outerplan;
+ *realinner = innerplan;
+ *elidedrealouter = elidedouter;
+ *elidedrealinner = elidedinner;
+ return strategy;
+}
+
+/*
+ * Descend through a Plan node in a join tree that the caller has determined
+ * to be irrelevant.
+ *
+ * Updates *plan, and returns the last of any elided nodes pertaining to the
+ * new plan node.
+ */
+static ElidedNode *
+pgpa_descend_node(PlannedStmt *pstmt, Plan **plan)
+{
+ *plan = (*plan)->lefttree;
+ return pgpa_last_elided_node(pstmt, *plan);
+}
+
+/*
+ * Descend through a Gather or Gather Merge node, if present, and any Sort
+ * or IncrementalSort node occurring under a Gather Merge.
+ *
+ * Caller should have verified that there is no ElidedNode pertaining to
+ * the initial value of *plan.
+ *
+ * Updates *plan, and returns the last of any elided nodes pertaining to the
+ * new plan node. Sets *found_any_gather = true if either Gather or
+ * Gather Merge was found, and otherwise leaves it unchanged.
+ */
+static ElidedNode *
+pgpa_descend_any_gather(PlannedStmt *pstmt, Plan **plan,
+ bool *found_any_gather)
+{
+ if (IsA(*plan, Gather))
+ {
+ *found_any_gather = true;
+ return pgpa_descend_node(pstmt, plan);
+ }
+
+ if (IsA(*plan, GatherMerge))
+ {
+ ElidedNode *elided = pgpa_descend_node(pstmt, plan);
+
+ if (elided == NULL && is_sorting_plan(*plan))
+ elided = pgpa_descend_node(pstmt, plan);
+
+ *found_any_gather = true;
+ return elided;
+ }
+
+ return NULL;
+}
+
+/*
+ * If *plan is an Agg or Unique node, we want to descend through it, unless
+ * it has a corresponding elided node. If its immediate child is a Sort or
+ * IncrementalSort, we also want to descend through that, unless it has a
+ * corresponding elided node.
+ *
+ * On entry, *elided_node must be the last of any elided nodes corresponding
+ * to *plan; on exit, this will still be true, but *plan may have been updated.
+ *
+ * The reason we don't want to descend through elided nodes is that a single
+ * join tree can't cross through any sort of elided node: subqueries are
+ * planned separately, and planning inside an Append or MergeAppend is
+ * separate from planning outside of it.
+ *
+ * The return value is true if we descend through at least one node, and
+ * otherwise false.
+ */
+static bool
+pgpa_descend_any_unique(PlannedStmt *pstmt, Plan **plan,
+ ElidedNode **elided_node)
+{
+ if (*elided_node != NULL)
+ return false;
+
+ if (IsA(*plan, Agg) || IsA(*plan, Unique))
+ {
+ *elided_node = pgpa_descend_node(pstmt, plan);
+
+ if (*elided_node == NULL && is_sorting_plan(*plan))
+ *elided_node = pgpa_descend_node(pstmt, plan);
+
+ return true;
+ }
+
+ return false;
+}
+
+/*
+ * Is this a Result node that has a child?
+ */
+static bool
+is_result_node_with_child(Plan *plan)
+{
+ return IsA(plan, Result) && plan->lefttree != NULL;
+}
+
+/*
+ * Is this a Plan node whose purpose is put the data in a certain order?
+ */
+static bool
+is_sorting_plan(Plan *plan)
+{
+ return IsA(plan, Sort) || IsA(plan, IncrementalSort);
+}
--- /dev/null
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_join.h
+ * analysis of joins in Plan trees
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_join.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_JOIN_H
+#define PGPA_JOIN_H
+
+#include "nodes/plannodes.h"
+
+typedef struct pgpa_plan_walker_context pgpa_plan_walker_context;
+typedef struct pgpa_join_unroller pgpa_join_unroller;
+typedef struct pgpa_unrolled_join pgpa_unrolled_join;
+
+/*
+ * Although there are three main join strategies, we try to classify things
+ * more precisely here: merge joins have the option of using materialization
+ * on the inner side, and nested loops can use either materialization or
+ * memoization.
+ */
+typedef enum
+{
+ JSTRAT_MERGE_JOIN_PLAIN = 0,
+ JSTRAT_MERGE_JOIN_MATERIALIZE,
+ JSTRAT_NESTED_LOOP_PLAIN,
+ JSTRAT_NESTED_LOOP_MATERIALIZE,
+ JSTRAT_NESTED_LOOP_MEMOIZE,
+ JSTRAT_HASH_JOIN
+ /* update NUM_PGPA_JOIN_STRATEGY if you add anything here */
+} pgpa_join_strategy;
+
+#define NUM_PGPA_JOIN_STRATEGY ((int) JSTRAT_HASH_JOIN + 1)
+
+/*
+ * In an outer-deep join tree, every member of an unrolled join will be a scan,
+ * but join trees with other shapes can contain unrolled joins.
+ *
+ * The plan node we store here will be the inner or outer child of the join
+ * node, as appropriate, except that we look through subnodes that we regard as
+ * part of the join method itself. For instance, for a Nested Loop that
+ * materializes the inner input, we'll store the child of the Materialize node,
+ * not the Materialize node itself.
+ *
+ * If setrefs processing elided one or more nodes from the plan tree, then
+ * we'll store details about the topmost of those in elided_node; otherwise,
+ * it will be NULL.
+ *
+ * Exactly one of scan and unrolled_join will be non-NULL.
+ */
+typedef struct
+{
+ Plan *plan;
+ ElidedNode *elided_node;
+ struct pgpa_scan *scan;
+ pgpa_unrolled_join *unrolled_join;
+} pgpa_join_member;
+
+/*
+ * We convert outer-deep join trees to a flat structure; that is, ((A JOIN B)
+ * JOIN C) JOIN D gets converted to outer = A, inner = <B C D>. When joins
+ * aren't outer-deep, substructure is required, e.g. (A JOIN B) JOIN (C JOIN D)
+ * is represented as outer = A, inner = <B X>, where X is a pgpa_unrolled_join
+ * covering C-D.
+ */
+struct pgpa_unrolled_join
+{
+ /* Outermost member; must not itself be an unrolled join. */
+ pgpa_join_member outer;
+
+ /* Number of inner members. Length of the strategy and inner arrays. */
+ unsigned ninner;
+
+ /* Array of strategies, one per non-outermost member. */
+ pgpa_join_strategy *strategy;
+
+ /* Array of members, excluding the outermost. Deepest first. */
+ pgpa_join_member *inner;
+};
+
+/*
+ * Does this plan node inherit from Join?
+ */
+static inline bool
+pgpa_is_join(Plan *plan)
+{
+ return IsA(plan, NestLoop) || IsA(plan, MergeJoin) || IsA(plan, HashJoin);
+}
+
+extern pgpa_join_unroller *pgpa_create_join_unroller(void);
+extern void pgpa_unroll_join(pgpa_plan_walker_context *walker,
+ Plan *plan, bool beneath_any_gather,
+ pgpa_join_unroller *join_unroller,
+ pgpa_join_unroller **outer_join_unroller,
+ pgpa_join_unroller **inner_join_unroller);
+extern pgpa_unrolled_join *pgpa_build_unrolled_join(pgpa_plan_walker_context *walker,
+ pgpa_join_unroller *join_unroller);
+extern void pgpa_destroy_join_unroller(pgpa_join_unroller *join_unroller);
+
+#endif
--- /dev/null
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_output.c
+ * produce textual output from the results of a plan tree walk
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_output.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgpa_output.h"
+#include "pgpa_scan.h"
+
+#include "nodes/parsenodes.h"
+#include "parser/parsetree.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+
+/*
+ * Context object for textual advice generation.
+ *
+ * rt_identifiers is the caller-provided array of range table identifiers.
+ * See the comments at the top of pgpa_identifier.c for more details.
+ *
+ * buf is the caller-provided output buffer.
+ *
+ * wrap_column is the wrap column, so that we don't create output that is
+ * too wide. See pgpa_maybe_linebreak() and comments in pgpa_output_advice.
+ */
+typedef struct pgpa_output_context
+{
+ const char **rid_strings;
+ StringInfo buf;
+ int wrap_column;
+} pgpa_output_context;
+
+static void pgpa_output_unrolled_join(pgpa_output_context *context,
+ pgpa_unrolled_join *join);
+static void pgpa_output_join_member(pgpa_output_context *context,
+ pgpa_join_member *member);
+static void pgpa_output_scan_strategy(pgpa_output_context *context,
+ pgpa_scan_strategy strategy,
+ List *scans);
+static void pgpa_output_bitmap_index_details(pgpa_output_context *context,
+ Plan *plan);
+static void pgpa_output_relation_name(pgpa_output_context *context, Oid relid);
+static void pgpa_output_query_feature(pgpa_output_context *context,
+ pgpa_qf_type type,
+ List *query_features);
+static void pgpa_output_simple_strategy(pgpa_output_context *context,
+ char *strategy,
+ List *relid_sets);
+static void pgpa_output_no_gather(pgpa_output_context *context,
+ Bitmapset *relids);
+static void pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
+ Bitmapset *relids);
+
+static char *pgpa_cstring_join_strategy(pgpa_join_strategy strategy);
+static char *pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy);
+static char *pgpa_cstring_query_feature_type(pgpa_qf_type type);
+
+static void pgpa_maybe_linebreak(StringInfo buf, int wrap_column);
+
+/*
+ * Append query advice to the provided buffer.
+ *
+ * Before calling this function, 'walker' must be used to iterate over the
+ * main plan tree and all subplans from the PlannedStmt.
+ *
+ * 'rt_identifiers' is a table of unique identifiers, one for each RTI.
+ * See pgpa_create_identifiers_for_planned_stmt().
+ *
+ * Results will be appended to 'buf'.
+ */
+void
+pgpa_output_advice(StringInfo buf, pgpa_plan_walker_context *walker,
+ pgpa_identifier *rt_identifiers)
+{
+ Index rtable_length = list_length(walker->pstmt->rtable);
+ ListCell *lc;
+ pgpa_output_context context;
+
+ /* Basic initialization. */
+ memset(&context, 0, sizeof(pgpa_output_context));
+ context.buf = buf;
+
+ /*
+ * Convert identifiers to string form. Note that the loop variable here is
+ * not an RTI, because RTIs are 1-based. Some RTIs will have no
+ * identifier, either because the reloptkind is RTE_JOIN or because that
+ * portion of the query didn't make it into the final plan.
+ */
+ context.rid_strings = palloc0_array(const char *, rtable_length);
+ for (int i = 0; i < rtable_length; ++i)
+ if (rt_identifiers[i].alias_name != NULL)
+ context.rid_strings[i] = pgpa_identifier_string(&rt_identifiers[i]);
+
+ /*
+ * If the user chooses to use EXPLAIN (PLAN_ADVICE) in an 80-column window
+ * from a psql client with default settings, psql will add one space to
+ * the left of the output and EXPLAIN will add two more to the left of the
+ * advice. Thus, lines of more than 77 characters will wrap. We set the
+ * wrap limit to 76 here so that the output won't reach all the way to the
+ * very last column of the terminal.
+ *
+ * Of course, this is fairly arbitrary set of assumptions, and one could
+ * well make an argument for a different wrap limit, or for a configurable
+ * one.
+ */
+ context.wrap_column = 76;
+
+ /*
+ * Each piece of JOIN_ORDER() advice fully describes the join order for a
+ * a single unrolled join. Merging is not permitted, because that would
+ * change the meaning, e.g. SEQ_SCAN(a b c d) means simply that sequential
+ * scans should be used for all of those relations, and is thus equivalent
+ * to SEQ_SCAN(a b) SEQ_SCAN(c d), but JOIN_ORDER(a b c d) means that "a"
+ * is the driving table which is then joined to "b" then "c" then "d",
+ * which is totally different from JOIN_ORDER(a b) and JOIN_ORDER(c d).
+ */
+ foreach(lc, walker->toplevel_unrolled_joins)
+ {
+ pgpa_unrolled_join *ujoin = lfirst(lc);
+
+ if (buf->len > 0)
+ appendStringInfoChar(buf, '\n');
+ appendStringInfo(context.buf, "JOIN_ORDER(");
+ pgpa_output_unrolled_join(&context, ujoin);
+ appendStringInfoChar(context.buf, ')');
+ pgpa_maybe_linebreak(context.buf, context.wrap_column);
+ }
+
+ /* Emit join strategy advice. */
+ for (int s = 0; s < NUM_PGPA_JOIN_STRATEGY; ++s)
+ {
+ char *strategy = pgpa_cstring_join_strategy(s);
+
+ pgpa_output_simple_strategy(&context,
+ strategy,
+ walker->join_strategies[s]);
+ }
+
+ /*
+ * Emit scan strategy advice (but not for ordinary scans, which are
+ * definitionally uninteresting).
+ */
+ for (int c = 0; c < NUM_PGPA_SCAN_STRATEGY; ++c)
+ if (c != PGPA_SCAN_ORDINARY)
+ pgpa_output_scan_strategy(&context, c, walker->scans[c]);
+
+ /* Emit query feature advice. */
+ for (int t = 0; t < NUM_PGPA_QF_TYPES; ++t)
+ pgpa_output_query_feature(&context, t, walker->query_features[t]);
+
+ /* Emit NO_GATHER advice. */
+ pgpa_output_no_gather(&context, walker->no_gather_scans);
+}
+
+/*
+ * Output the members of an unrolled join, first the outermost member, and
+ * then the inner members one by one, as part of JOIN_ORDER() advice.
+ */
+static void
+pgpa_output_unrolled_join(pgpa_output_context *context,
+ pgpa_unrolled_join *join)
+{
+ pgpa_output_join_member(context, &join->outer);
+
+ for (int k = 0; k < join->ninner; ++k)
+ {
+ pgpa_join_member *member = &join->inner[k];
+
+ pgpa_maybe_linebreak(context->buf, context->wrap_column);
+ appendStringInfoChar(context->buf, ' ');
+ pgpa_output_join_member(context, member);
+ }
+}
+
+/*
+ * Output a single member of an unrolled join as part of JOIN_ORDER() advice.
+ */
+static void
+pgpa_output_join_member(pgpa_output_context *context,
+ pgpa_join_member *member)
+{
+ if (member->unrolled_join != NULL)
+ {
+ appendStringInfoChar(context->buf, '(');
+ pgpa_output_unrolled_join(context, member->unrolled_join);
+ appendStringInfoChar(context->buf, ')');
+ }
+ else
+ {
+ pgpa_scan *scan = member->scan;
+
+ Assert(scan != NULL);
+ if (bms_membership(scan->relids) == BMS_SINGLETON)
+ pgpa_output_relations(context, context->buf, scan->relids);
+ else
+ {
+ appendStringInfoChar(context->buf, '{');
+ pgpa_output_relations(context, context->buf, scan->relids);
+ appendStringInfoChar(context->buf, '}');
+ }
+ }
+}
+
+/*
+ * Output advice for a List of pgpa_scan objects.
+ *
+ * All the scans must use the strategy specified by the "strategy" argument.
+ */
+static void
+pgpa_output_scan_strategy(pgpa_output_context *context,
+ pgpa_scan_strategy strategy,
+ List *scans)
+{
+ bool first = true;
+
+ if (scans == NIL)
+ return;
+
+ if (context->buf->len > 0)
+ appendStringInfoChar(context->buf, '\n');
+ appendStringInfo(context->buf, "%s(",
+ pgpa_cstring_scan_strategy(strategy));
+
+ foreach_ptr(pgpa_scan, scan, scans)
+ {
+ Plan *plan = scan->plan;
+
+ if (first)
+ first = false;
+ else
+ {
+ pgpa_maybe_linebreak(context->buf, context->wrap_column);
+ appendStringInfoChar(context->buf, ' ');
+ }
+
+ /* Output the relation identifiers. */
+ if (bms_membership(scan->relids) == BMS_SINGLETON)
+ pgpa_output_relations(context, context->buf, scan->relids);
+ else
+ {
+ appendStringInfoChar(context->buf, '(');
+ pgpa_output_relations(context, context->buf, scan->relids);
+ appendStringInfoChar(context->buf, ')');
+ }
+
+ /* For scans involving indexes, output index information. */
+ if (strategy == PGPA_SCAN_INDEX)
+ {
+ Assert(IsA(plan, IndexScan));
+ pgpa_maybe_linebreak(context->buf, context->wrap_column);
+ appendStringInfoChar(context->buf, ' ');
+ pgpa_output_relation_name(context, ((IndexScan *) plan)->indexid);
+ }
+ else if (strategy == PGPA_SCAN_INDEX_ONLY)
+ {
+ Assert(IsA(plan, IndexOnlyScan));
+ pgpa_maybe_linebreak(context->buf, context->wrap_column);
+ appendStringInfoChar(context->buf, ' ');
+ pgpa_output_relation_name(context,
+ ((IndexOnlyScan *) plan)->indexid);
+ }
+ else if (strategy == PGPA_SCAN_BITMAP_HEAP)
+ {
+ pgpa_maybe_linebreak(context->buf, context->wrap_column);
+ appendStringInfoChar(context->buf, ' ');
+ pgpa_output_bitmap_index_details(context, plan->lefttree);
+ }
+ }
+
+ appendStringInfoChar(context->buf, ')');
+ pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output information about which index or indexes power a BitmapHeapScan.
+ *
+ * We emit &&(i1 i2 i3) for a BitmapAnd between indexes i1, i2, and i3;
+ * and likewise ||(i1 i2 i3) for a similar BitmapOr operation.
+ */
+static void
+pgpa_output_bitmap_index_details(pgpa_output_context *context, Plan *plan)
+{
+ char *operator;
+ List *bitmapplans;
+ bool first = true;
+
+ if (IsA(plan, BitmapIndexScan))
+ {
+ BitmapIndexScan *bitmapindexscan = (BitmapIndexScan *) plan;
+
+ pgpa_output_relation_name(context, bitmapindexscan->indexid);
+ return;
+ }
+
+ if (IsA(plan, BitmapOr))
+ {
+ operator = "||";
+ bitmapplans = ((BitmapOr *) plan)->bitmapplans;
+ }
+ else if (IsA(plan, BitmapAnd))
+ {
+ operator = "&&";
+ bitmapplans = ((BitmapAnd *) plan)->bitmapplans;
+ }
+ else
+ elog(ERROR, "unexpected node type: %d", (int) nodeTag(plan));
+
+ appendStringInfo(context->buf, "%s(", operator);
+ foreach_ptr(Plan, child_plan, bitmapplans)
+ {
+ if (first)
+ first = false;
+ else
+ {
+ pgpa_maybe_linebreak(context->buf, context->wrap_column);
+ appendStringInfoChar(context->buf, ' ');
+ }
+ pgpa_output_bitmap_index_details(context, child_plan);
+ }
+ appendStringInfoChar(context->buf, ')');
+}
+
+/*
+ * Output a schema-qualified relation name.
+ */
+static void
+pgpa_output_relation_name(pgpa_output_context *context, Oid relid)
+{
+ Oid nspoid = get_rel_namespace(relid);
+ char *relnamespace = get_namespace_name_or_temp(nspoid);
+ char *relname = get_rel_name(relid);
+
+ appendStringInfoString(context->buf, quote_identifier(relnamespace));
+ appendStringInfoChar(context->buf, '.');
+ appendStringInfoString(context->buf, quote_identifier(relname));
+}
+
+/*
+ * Output advice for a List of pgpa_query_feature objects.
+ *
+ * All features must be of the type specified by the "type" argument.
+ */
+static void
+pgpa_output_query_feature(pgpa_output_context *context, pgpa_qf_type type,
+ List *query_features)
+{
+ bool first = true;
+
+ if (query_features == NIL)
+ return;
+
+ if (context->buf->len > 0)
+ appendStringInfoChar(context->buf, '\n');
+ appendStringInfo(context->buf, "%s(",
+ pgpa_cstring_query_feature_type(type));
+
+ foreach_ptr(pgpa_query_feature, qf, query_features)
+ {
+ if (first)
+ first = false;
+ else
+ {
+ pgpa_maybe_linebreak(context->buf, context->wrap_column);
+ appendStringInfoChar(context->buf, ' ');
+ }
+
+ if (bms_membership(qf->relids) == BMS_SINGLETON)
+ pgpa_output_relations(context, context->buf, qf->relids);
+ else
+ {
+ appendStringInfoChar(context->buf, '(');
+ pgpa_output_relations(context, context->buf, qf->relids);
+ appendStringInfoChar(context->buf, ')');
+ }
+ }
+
+ appendStringInfoChar(context->buf, ')');
+ pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output "simple" advice for a List of Bitmapset objects each of which
+ * contains one or more RTIs.
+ *
+ * By simple, we just mean that the advice emitted follows the most
+ * straightforward pattern: the strategy name, followed by a list of items
+ * separated by spaces and surrounded by parentheses. Individual items in
+ * the list are a single relation identifier for a Bitmapset that contains
+ * just one member, or a sub-list again separated by spaces and surrounded
+ * by parentheses for a Bitmapset with multiple members. Bitmapsets with
+ * no members probably shouldn't occur here, but if they do they'll be
+ * rendered as an empty sub-list.
+ */
+static void
+pgpa_output_simple_strategy(pgpa_output_context *context, char *strategy,
+ List *relid_sets)
+{
+ bool first = true;
+
+ if (relid_sets == NIL)
+ return;
+
+ if (context->buf->len > 0)
+ appendStringInfoChar(context->buf, '\n');
+ appendStringInfo(context->buf, "%s(", strategy);
+
+ foreach_node(Bitmapset, relids, relid_sets)
+ {
+ if (first)
+ first = false;
+ else
+ {
+ pgpa_maybe_linebreak(context->buf, context->wrap_column);
+ appendStringInfoChar(context->buf, ' ');
+ }
+
+ if (bms_membership(relids) == BMS_SINGLETON)
+ pgpa_output_relations(context, context->buf, relids);
+ else
+ {
+ appendStringInfoChar(context->buf, '(');
+ pgpa_output_relations(context, context->buf, relids);
+ appendStringInfoChar(context->buf, ')');
+ }
+ }
+
+ appendStringInfoChar(context->buf, ')');
+ pgpa_maybe_linebreak(context->buf, context->wrap_column);
+}
+
+/*
+ * Output NO_GATHER advice for all relations not appearing beneath any
+ * Gather or Gather Merge node.
+ */
+static void
+pgpa_output_no_gather(pgpa_output_context *context, Bitmapset *relids)
+{
+ if (relids == NULL)
+ return;
+ if (context->buf->len > 0)
+ appendStringInfoChar(context->buf, '\n');
+ appendStringInfoString(context->buf, "NO_GATHER(");
+ pgpa_output_relations(context, context->buf, relids);
+ appendStringInfoChar(context->buf, ')');
+}
+
+/*
+ * Output the identifiers for each RTI in the provided set.
+ *
+ * Identifiers are separated by spaces, and a line break is possible after
+ * each one.
+ */
+static void
+pgpa_output_relations(pgpa_output_context *context, StringInfo buf,
+ Bitmapset *relids)
+{
+ int rti = -1;
+ bool first = true;
+
+ while ((rti = bms_next_member(relids, rti)) >= 0)
+ {
+ const char *rid_string = context->rid_strings[rti - 1];
+
+ if (rid_string == NULL)
+ elog(ERROR, "no identifier for RTI %d", rti);
+
+ if (first)
+ {
+ first = false;
+ appendStringInfoString(buf, rid_string);
+ }
+ else
+ {
+ pgpa_maybe_linebreak(buf, context->wrap_column);
+ appendStringInfo(buf, " %s", rid_string);
+ }
+ }
+}
+
+/*
+ * Get a C string that corresponds to the specified join strategy.
+ */
+static char *
+pgpa_cstring_join_strategy(pgpa_join_strategy strategy)
+{
+ switch (strategy)
+ {
+ case JSTRAT_MERGE_JOIN_PLAIN:
+ return "MERGE_JOIN_PLAIN";
+ case JSTRAT_MERGE_JOIN_MATERIALIZE:
+ return "MERGE_JOIN_MATERIALIZE";
+ case JSTRAT_NESTED_LOOP_PLAIN:
+ return "NESTED_LOOP_PLAIN";
+ case JSTRAT_NESTED_LOOP_MATERIALIZE:
+ return "NESTED_LOOP_MATERIALIZE";
+ case JSTRAT_NESTED_LOOP_MEMOIZE:
+ return "NESTED_LOOP_MEMOIZE";
+ case JSTRAT_HASH_JOIN:
+ return "HASH_JOIN";
+ }
+
+ pg_unreachable();
+ return NULL;
+}
+
+/*
+ * Get a C string that corresponds to the specified scan strategy.
+ */
+static char *
+pgpa_cstring_scan_strategy(pgpa_scan_strategy strategy)
+{
+ switch (strategy)
+ {
+ case PGPA_SCAN_ORDINARY:
+ return "ORDINARY_SCAN";
+ case PGPA_SCAN_SEQ:
+ return "SEQ_SCAN";
+ case PGPA_SCAN_BITMAP_HEAP:
+ return "BITMAP_HEAP_SCAN";
+ case PGPA_SCAN_FOREIGN:
+ return "FOREIGN_JOIN";
+ case PGPA_SCAN_INDEX:
+ return "INDEX_SCAN";
+ case PGPA_SCAN_INDEX_ONLY:
+ return "INDEX_ONLY_SCAN";
+ case PGPA_SCAN_PARTITIONWISE:
+ return "PARTITIONWISE";
+ case PGPA_SCAN_TID:
+ return "TID_SCAN";
+ }
+
+ pg_unreachable();
+ return NULL;
+}
+
+/*
+ * Get a C string that corresponds to the specified scan strategy.
+ */
+static char *
+pgpa_cstring_query_feature_type(pgpa_qf_type type)
+{
+ switch (type)
+ {
+ case PGPAQF_GATHER:
+ return "GATHER";
+ case PGPAQF_GATHER_MERGE:
+ return "GATHER_MERGE";
+ case PGPAQF_SEMIJOIN_NON_UNIQUE:
+ return "SEMIJOIN_NON_UNIQUE";
+ case PGPAQF_SEMIJOIN_UNIQUE:
+ return "SEMIJOIN_UNIQUE";
+ }
+
+
+ pg_unreachable();
+ return NULL;
+}
+
+/*
+ * Insert a line break into the StringInfoData, if needed.
+ *
+ * If wrap_column is zero or negative, this does nothing. Otherwise, we
+ * consider inserting a newline. We only insert a newline if the length of
+ * the last line in the buffer exceeds wrap_column, and not if we'd be
+ * inserting a newline at or before the beginning of the current line.
+ *
+ * The position at which the newline is inserted is simply wherever the
+ * buffer ended the last time this function was called. In other words,
+ * the caller is expected to call this function every time we reach a good
+ * place for a line break.
+ */
+static void
+pgpa_maybe_linebreak(StringInfo buf, int wrap_column)
+{
+ char *trailing_nl;
+ int line_start;
+ int save_cursor;
+
+ /* If line wrapping is disabled, exit quickly. */
+ if (wrap_column <= 0)
+ return;
+
+ /*
+ * Set line_start to the byte offset within buf->data of the first
+ * character of the current line, where the current line means the last
+ * one in the buffer. Note that line_start could be the offset of the
+ * trailing '\0' if the last character in the buffer is a line break.
+ */
+ trailing_nl = strrchr(buf->data, '\n');
+ if (trailing_nl == NULL)
+ line_start = 0;
+ else
+ line_start = (trailing_nl - buf->data) + 1;
+
+ /*
+ * Remember that the current end of the buffer is a potential location to
+ * insert a line break on a future call to this function.
+ */
+ save_cursor = buf->cursor;
+ buf->cursor = buf->len;
+
+ /* If we haven't passed the wrap column, we don't need a newline. */
+ if (buf->len - line_start <= wrap_column)
+ return;
+
+ /*
+ * It only makes sense to insert a newline at a position later than the
+ * beginning of the current line.
+ */
+ if (buf->cursor <= line_start)
+ return;
+
+ /* Insert a newline at the previous cursor location. */
+ enlargeStringInfo(buf, 1);
+ memmove(&buf->data[save_cursor] + 1, &buf->data[save_cursor],
+ buf->len - save_cursor);
+ ++buf->cursor;
+ buf->data[++buf->len] = '\0';
+ buf->data[save_cursor] = '\n';
+}
--- /dev/null
+/*-------------------------------------------------------------------------
+ *
+ * pgpa_output.h
+ * produce textual output from the results of a plan tree walk
+ *
+ * Copyright (c) 2016-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_output.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGPA_OUTPUT_H
+#define PGPA_OUTPUT_H
+
+#include "pgpa_identifier.h"
+#include "pgpa_walker.h"
+
+extern void pgpa_output_advice(StringInfo buf,
+ pgpa_plan_walker_context *walker,
+ pgpa_identifier *rt_identifiers);
+
+#endif
--- /dev/null
+%{
+/*
+ * Parser for plan advice
+ *
+ * Copyright (c) 2000-2025, PostgreSQL Global Development Group
+ *
+ * contrib/pg_plan_advice/pgpa_parser.y
+ */
+
+#include "postgres.h"
+
+#include <float.h>
+#include <math.h>
+
+#include "fmgr.h"
+#include "nodes/miscnodes.h"
+#include "utils/builtins.h"
+#include "utils/float.h"
+
+#include "pgpa_ast.h"
+#include "pgpa_parser.h"
+
+/*
+ * Bison doesn't allocate anything that needs to live across parser calls,
+ * so we can easily have it use palloc instead of malloc. This prevents
+ * memory leaks if we error out during parsing.
+ */
+#define YYMALLOC palloc
+#define YYFREE pfree
+%}
+
+/* BISON Declarations */
+%parse-param {List **result}
+%parse-param {char **parse_error_msg_p}
+%parse-param {yyscan_t yyscanner}
+%lex-param {List **result}
+%lex-param {char **parse_error_msg_p}
+%lex-param {yyscan_t yyscanner}
+%pure-parser
+%expect 0
+%name-prefix="pgpa_yy"
+
+%union
+{
+ char *str;
+ int integer;
+ List *list;
+ pgpa_advice_item *item;
+ pgpa_advice_target *target;
+ pgpa_index_target *itarget;
+}
+%token <str> TOK_IDENT TOK_TAG_JOIN_ORDER TOK_TAG_BITMAP TOK_TAG_INDEX
+%token <str> TOK_TAG_SIMPLE TOK_TAG_GENERIC
+%token <integer> TOK_INTEGER
+%token TOK_OR TOK_AND
+
+%type <integer> opt_ri_occurrence
+%type <item> advice_item
+%type <list> advice_item_list bitmap_sublist bitmap_target_list generic_target_list
+%type <list> index_target_list join_order_target_list
+%type <list> opt_partition simple_target_list
+%type <str> identifier opt_plan_name
+%type <target> generic_sublist join_order_sublist
+%type <target> relation_identifier
+%type <itarget> bitmap_target_item index_name
+
+%start parse_toplevel
+
+/* Grammar follows */
+%%
+
+parse_toplevel: advice_item_list
+ {
+ (void) yynerrs; /* suppress compiler warning */
+ *result = $1;
+ }
+ ;
+
+advice_item_list: advice_item_list advice_item
+ { $$ = lappend($1, $2); }
+ |
+ { $$ = NIL; }
+ ;
+
+advice_item: TOK_TAG_JOIN_ORDER '(' join_order_target_list ')'
+ {
+ $$ = palloc0_object(pgpa_advice_item);
+ $$->tag = PGPA_TAG_JOIN_ORDER;
+ $$->targets = $3;
+ }
+ | TOK_TAG_INDEX '(' index_target_list ')'
+ {
+ $$ = palloc0_object(pgpa_advice_item);
+ if (strcmp($1, "index_only_scan") == 0)
+ $$->tag = PGPA_TAG_INDEX_ONLY_SCAN;
+ else if (strcmp($1, "index_scan") == 0)
+ $$->tag = PGPA_TAG_INDEX_SCAN;
+ else
+ elog(ERROR, "tag parsing failed: %s", $1);
+ $$->targets = $3;
+ }
+ | TOK_TAG_BITMAP '(' bitmap_target_list ')'
+ {
+ $$ = palloc0_object(pgpa_advice_item);
+ $$->tag = PGPA_TAG_BITMAP_HEAP_SCAN;
+ $$->targets = $3;
+ }
+ | TOK_TAG_SIMPLE '(' simple_target_list ')'
+ {
+ $$ = palloc0_object(pgpa_advice_item);
+ if (strcmp($1, "no_gather") == 0)
+ $$->tag = PGPA_TAG_NO_GATHER;
+ else if (strcmp($1, "seq_scan") == 0)
+ $$->tag = PGPA_TAG_SEQ_SCAN;
+ else if (strcmp($1, "tid_scan") == 0)
+ $$->tag = PGPA_TAG_TID_SCAN;
+ else
+ elog(ERROR, "tag parsing failed: %s", $1);
+ $$->targets = $3;
+ }
+ | TOK_TAG_GENERIC '(' generic_target_list ')'
+ {
+ bool fail;
+
+ $$ = palloc0_object(pgpa_advice_item);
+ $$->tag = pgpa_parse_advice_tag($1, &fail);
+ if (fail)
+ {
+ pgpa_yyerror(result, parse_error_msg_p, yyscanner,
+ "unrecognized advice tag");
+ }
+
+ if ($$->tag == PG