v5-0001-Simplify-planning-of-starjoin-queries.patch

text/x-patch

Filename: v5-0001-Simplify-planning-of-starjoin-queries.patch
Type: text/x-patch
Part: 1
Message: Re: should we have a fast-path planning for OLTP starjoins?

Patch

Same data as JSON: GET /api/v1/attachments/:id/patch the parsed metadata as JSON — format, series position, per-file stats; never the diff bytes. API reference →
Format: format-patch
Series: patch v5-0001
Subject: Simplify planning of starjoin queries
File+
patch/create-1.sql 18 0
patch/create-2.sql 11 0
patch/create-3.sql 32 0
patch/run.sh 23 0
patch/select-1-inner.sql 10 0
patch/select-1-outer.sql 10 0
patch/select-2.sql 9 0
patch/select-3-inner.sql 15 0
patch/select-3-outer.sql 15 0
src/backend/optimizer/path/joinrels.c 1 2
src/backend/optimizer/plan/analyzejoins.c 546 0
src/backend/optimizer/plan/planmain.c 7 0
src/backend/utils/misc/guc_parameters.dat 7 0
src/backend/utils/misc/postgresql.conf.sample 1 0
src/include/optimizer/paths.h 1 0
src/include/optimizer/planmain.h 2 0
src/test/regress/expected/sysviews.out 2 1
From 162d2e9d876304e9cffa9a0fd73cfea33ca74819 Mon Sep 17 00:00:00 2001
From: Tomas Vondra <tomas@vondra.me>
Date: Sat, 8 Nov 2025 18:16:30 +0100
Subject: [PATCH v5 1/2] Simplify planning of starjoin queries

---
 patch/create-1.sql                            |  18 +
 patch/create-2.sql                            |  11 +
 patch/create-3.sql                            |  32 +
 patch/run.sh                                  |  23 +
 patch/select-1-inner.sql                      |  10 +
 patch/select-1-outer.sql                      |  10 +
 patch/select-2.sql                            |   9 +
 patch/select-3-inner.sql                      |  15 +
 patch/select-3-outer.sql                      |  15 +
 src/backend/optimizer/path/joinrels.c         |   3 +-
 src/backend/optimizer/plan/analyzejoins.c     | 546 ++++++++++++++++++
 src/backend/optimizer/plan/planmain.c         |   7 +
 src/backend/utils/misc/guc_parameters.dat     |   7 +
 src/backend/utils/misc/postgresql.conf.sample |   1 +
 src/include/optimizer/paths.h                 |   1 +
 src/include/optimizer/planmain.h              |   2 +
 src/test/regress/expected/sysviews.out        |   3 +-
 17 files changed, 710 insertions(+), 3 deletions(-)
 create mode 100644 patch/create-1.sql
 create mode 100644 patch/create-2.sql
 create mode 100644 patch/create-3.sql
 create mode 100644 patch/run.sh
 create mode 100644 patch/select-1-inner.sql
 create mode 100644 patch/select-1-outer.sql
 create mode 100644 patch/select-2.sql
 create mode 100644 patch/select-3-inner.sql
 create mode 100644 patch/select-3-outer.sql

diff --git a/patch/create-1.sql b/patch/create-1.sql
new file mode 100644
index 00000000000..8df04fd0677
--- /dev/null
+++ b/patch/create-1.sql
@@ -0,0 +1,18 @@
+create table dim1 (id int primary key, val1 text);
+create table dim2 (id int primary key, val2 text);
+create table dim3 (id int primary key, val3 text);
+create table dim4 (id int primary key, val4 text);
+create table dim5 (id int primary key, val5 text);
+create table dim6 (id int primary key, val6 text);
+create table dim7 (id int primary key, val7 text);
+
+create table t (id serial primary key,
+                id1 int references dim1(id),
+                id2 int references dim2(id),
+                id3 int references dim3(id),
+                id4 int references dim4(id),
+                id5 int references dim5(id),
+                id6 int references dim6(id),
+                id7 int references dim7(id));
+
+vacuum analyze;
diff --git a/patch/create-2.sql b/patch/create-2.sql
new file mode 100644
index 00000000000..cdf612dde8f
--- /dev/null
+++ b/patch/create-2.sql
@@ -0,0 +1,11 @@
+create table t (id serial primary key, a text);
+
+create table dim1 (id1 int primary key references t(id), val1 text);
+create table dim2 (id2 int primary key references t(id), val2 text);
+create table dim3 (id3 int primary key references t(id), val3 text);
+create table dim4 (id4 int primary key references t(id), val4 text);
+create table dim5 (id5 int primary key references t(id), val5 text);
+create table dim6 (id6 int primary key references t(id), val6 text);
+create table dim7 (id7 int primary key references t(id), val7 text);
+
+vacuum analyze;
diff --git a/patch/create-3.sql b/patch/create-3.sql
new file mode 100644
index 00000000000..bd086c137ce
--- /dev/null
+++ b/patch/create-3.sql
@@ -0,0 +1,32 @@
+create table dim1_1 (id int primary key, val2 text);
+create table dim1_2 (id int primary key, val3 text);
+create table dim1 (id int primary key,
+                   id1_1 int references dim1_1(id),
+                   id1_2 int references dim1_2(id),
+                   val1 text);
+create table dim2_1 (id int primary key, val5 text);
+create table dim2_2 (id int primary key, val6 text);
+create table dim2 (id int primary key,
+                   id2_1 int references dim2_1(id),
+                   id2_2 int references dim2_2(id),
+                   val4 text);
+create table dim3_1 (id int primary key, val5 text);
+create table dim3_2 (id int primary key, val6 text);
+create table dim3 (id int primary key,
+                   id3_1 int references dim3_1(id),
+                   id3_2 int references dim3_2(id),
+                   val4 text);
+create table dim4_1 (id int primary key, val5 text);
+create table dim4_2 (id int primary key, val6 text);
+create table dim4 (id int primary key,
+                   id4_1 int references dim4_1(id),
+                   id4_2 int references dim4_2(id),
+                   val4 text);
+
+create table t (id serial primary key,
+                id1 int references dim1(id),
+                id2 int references dim2(id),
+                id3 int references dim3(id),
+                id4 int references dim4(id));
+
+vacuum analyze;
diff --git a/patch/run.sh b/patch/run.sh
new file mode 100644
index 00000000000..c67cd848a4a
--- /dev/null
+++ b/patch/run.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+
+for t in 1 3; do
+
+	dropdb --if-exists test
+	createdb test
+	psql test < create-$t.sql > /dev/null 2>&1
+
+	for m in inner outer; do
+
+		for o in off on; do
+
+			sed "s/OPT/$o/" select-$t$m.sql > script.sql
+
+			tps=$(pgbench -n -f script.sql -T 10 test | grep 'tps = ' | awk '{print $3}')
+
+			echo $t $m $o $tps
+
+		done
+
+	done
+
+done
diff --git a/patch/select-1-inner.sql b/patch/select-1-inner.sql
new file mode 100644
index 00000000000..d3bd477eea3
--- /dev/null
+++ b/patch/select-1-inner.sql
@@ -0,0 +1,10 @@
+--set join_collapse_limit = 1;
+set enable_starjoin_join_search = OPT;
+select * from t
+    join dim1 on (dim1.id = id1)
+    join dim2 on (dim2.id = id2)
+    join dim3 on (dim3.id = id3)
+    join dim4 on (dim4.id = id4)
+    join dim5 on (dim5.id = id5)
+    join dim6 on (dim6.id = id6)
+    join dim7 on (dim7.id = id7);
diff --git a/patch/select-1-outer.sql b/patch/select-1-outer.sql
new file mode 100644
index 00000000000..249cde4b768
--- /dev/null
+++ b/patch/select-1-outer.sql
@@ -0,0 +1,10 @@
+--set join_collapse_limit = 1;
+set enable_starjoin_join_search = OPT;
+select * from t
+    left join dim1 on (dim1.id = id1)
+    left join dim2 on (dim2.id = id2)
+    left join dim3 on (dim3.id = id3)
+    left join dim4 on (dim4.id = id4)
+    left join dim5 on (dim5.id = id5)
+    left join dim6 on (dim6.id = id6)
+    left join dim7 on (dim7.id = id7);
diff --git a/patch/select-2.sql b/patch/select-2.sql
new file mode 100644
index 00000000000..4e1d2a7b0e7
--- /dev/null
+++ b/patch/select-2.sql
@@ -0,0 +1,9 @@
+-- set join_collapse_limit = 1;
+select * from t
+    left join dim1 on (id = id1)
+    left join dim2 on (id = id2)
+    left join dim3 on (id = id3)
+    left join dim4 on (id = id4)
+    left join dim5 on (id = id5)
+    left join dim6 on (id = id6)
+    left join dim7 on (id = id7);
diff --git a/patch/select-3-inner.sql b/patch/select-3-inner.sql
new file mode 100644
index 00000000000..f661c0c2fb5
--- /dev/null
+++ b/patch/select-3-inner.sql
@@ -0,0 +1,15 @@
+--set join_collapse_limit = 1;
+set enable_starjoin_join_search = OPT;
+select * from t
+    join dim1 on (dim1.id = id1)
+    join dim2 on (dim2.id = id2)
+    join dim3 on (dim3.id = id3)
+    join dim4 on (dim4.id = id4)
+    join dim1_1 on (id1_1 = dim1_1.id)
+    join dim1_2 on (id1_2 = dim1_2.id)
+    join dim2_1 on (id2_1 = dim2_1.id)
+    join dim2_2 on (id2_2 = dim2_2.id)
+    join dim3_1 on (id3_1 = dim3_1.id)
+    join dim3_2 on (id3_2 = dim3_2.id)
+    join dim4_1 on (id4_1 = dim4_1.id)
+    join dim4_2 on (id4_2 = dim4_2.id);
diff --git a/patch/select-3-outer.sql b/patch/select-3-outer.sql
new file mode 100644
index 00000000000..5ca549f02ac
--- /dev/null
+++ b/patch/select-3-outer.sql
@@ -0,0 +1,15 @@
+--set join_collapse_limit = 1;
+set enable_starjoin_join_search = OPT;
+select * from t
+    left join dim1 on (dim1.id = id1)
+    left join dim2 on (dim2.id = id2)
+    left join dim3 on (dim3.id = id3)
+    left join dim4 on (dim4.id = id4)
+    left join dim1_1 on (id1_1 = dim1_1.id)
+    left join dim1_2 on (id1_2 = dim1_2.id)
+    left join dim2_1 on (id2_1 = dim2_1.id)
+    left join dim2_2 on (id2_2 = dim2_2.id)
+    left join dim3_1 on (id3_1 = dim3_1.id)
+    left join dim3_2 on (id3_2 = dim3_2.id)
+    left join dim4_1 on (id4_1 = dim4_1.id)
+    left join dim4_2 on (id4_2 = dim4_2.id);
diff --git a/src/backend/optimizer/path/joinrels.c b/src/backend/optimizer/path/joinrels.c
index 5d1fc3899da..17332350da5 100644
--- a/src/backend/optimizer/path/joinrels.c
+++ b/src/backend/optimizer/path/joinrels.c
@@ -32,7 +32,6 @@ static void make_rels_by_clause_joins(PlannerInfo *root,
 static void make_rels_by_clauseless_joins(PlannerInfo *root,
 										  RelOptInfo *old_rel,
 										  List *other_rels);
-static bool has_join_restriction(PlannerInfo *root, RelOptInfo *rel);
 static bool has_legal_joinclause(PlannerInfo *root, RelOptInfo *rel);
 static bool restriction_is_constant_false(List *restrictlist,
 										  RelOptInfo *joinrel,
@@ -1363,7 +1362,7 @@ have_join_order_restriction(PlannerInfo *root,
  * say "true" incorrectly.  (Therefore, we don't bother with the relatively
  * expensive has_legal_joinclause test.)
  */
-static bool
+bool
 has_join_restriction(PlannerInfo *root, RelOptInfo *rel)
 {
 	ListCell   *l;
diff --git a/src/backend/optimizer/plan/analyzejoins.c b/src/backend/optimizer/plan/analyzejoins.c
index e592e1ac3d1..79a7f0c8608 100644
--- a/src/backend/optimizer/plan/analyzejoins.c
+++ b/src/backend/optimizer/plan/analyzejoins.c
@@ -51,6 +51,7 @@ typedef struct
 } SelfJoinCandidate;
 
 bool		enable_self_join_elimination;
+bool		enable_starjoin_join_search;
 
 /* local functions */
 static bool join_is_removable(PlannerInfo *root, SpecialJoinInfo *sjinfo);
@@ -2511,3 +2512,548 @@ remove_useless_self_joins(PlannerInfo *root, List *joinlist)
 
 	return joinlist;
 }
+
+/*
+ * starjoin_foreign_key_matched_by_clauses
+ *		Determines if the foreign key is matched by join clauses.
+ *
+ * Each foreign key attribute must be matched by a join clause. Either by
+ * a simple RestrictInfo, or by an equivalence class (if the relation has
+ * eclass joins).
+ *
+ * Returns true if the foreign key is matched, false otherwise.
+ *
+ * XXX Do we need to check that the FK side of the join (i.e. the fact table)
+ * has the columns referenced as NOT NULL? Otherwise we could have a FK join
+ * that reduces the cardinality, which is one of the arguments why it's fine
+ * to move the join (that it doesn't change the cardinality). But if the join
+ * is LEFT JOIN, this should be fine too - but do we get here with LEFT JOINs?
+ */
+static bool
+starjoin_foreign_key_matched_by_clauses(PlannerInfo *root, RelOptInfo *rel,
+										ForeignKeyOptInfo *fkinfo)
+{
+	/* make sure each FK attribute has at least one matching clause */
+	for (int i = 0; i < fkinfo->nkeys; i++)
+	{
+		/* matching rinfo clause or equivalance class */
+		if ((fkinfo->rinfos[i] != NULL) || (fkinfo->eclass[i] != NULL))
+		{
+			/*
+			 * XXX Do we need to inspect the rinfo/eclass further? I don't
+			 * think that's really necessary, we've already inspected it when
+			 * building ForeignKeyOptInfo. So existence should be enough.
+			 */
+			continue;
+		}
+
+		/* found an attribute without a join clause, ignore this FK */
+		return false;
+	}
+
+	/* all FK attributes had a matching join clause */
+	return true;
+}
+
+/*
+ * starjoin_clauses_matched_by_foreign_key
+ *		Are all join clauses (rinfo or eclass) matched by the foreign key?
+ *
+ * Check if all join clauses for the current relation match the foreign key.
+ * Returns true if that's the case, false otherwise.
+ *
+ * XXX Could we do the check in both directions at once? Essentially, merge
+ * this with starjoin_foreign_key_matched_by_clauses, in a way that would
+ * make it cheaper than two separate functions? I don't think so.
+ */
+static bool
+starjoin_clauses_matched_by_foreign_key(PlannerInfo *root, RelOptInfo *rel,
+										ForeignKeyOptInfo *fkinfo)
+{
+	ListCell   *lc;
+	int			j;
+
+	/* Inspect all simple (RestrictInfo) clauses. */
+	foreach(lc, rel->joininfo)
+	{
+		RestrictInfo *rinfo = (RestrictInfo *) lfirst(lc);
+		bool		matched = false;	/* matched to the foreign key */
+
+		/* Is there a FK attribute referencing this rinfo? */
+		for (int i = 0; i < fkinfo->nkeys; i++)
+		{
+			if (list_member_ptr(fkinfo->rinfos[i], rinfo))
+			{
+				matched = true;
+				break;
+			}
+		}
+
+		/* found a clause not matched for FK */
+		if (!matched)
+			return false;
+	}
+
+	/* If the rel does not have eclass joins, we're done. */
+	if (!rel->has_eclass_joins)
+		return true;
+
+	/*
+	 * There should be joins with clauses in equivalence classes. Walk all the
+	 * eclasses, and try to match them to the foreign key.
+	 */
+	j = -1;
+	while ((j = bms_next_member(rel->eclass_indexes, j)) >= 0)
+	{
+		EquivalenceClass *ec = (EquivalenceClass *) list_nth(root->eq_classes, j);
+		bool		matched = false;	/* matched to the foreign key */
+
+		/*
+		 * We're interested in joins, and const or single-member EC won't
+		 * generate join clauses. So skip them now, before walking the FK.
+		 * (The latter test covers the volatile case too.)
+		 *
+		 * XXX Taken from generate_implied_equalities_for_column().
+		 */
+		if (ec->ec_has_const || list_length(ec->ec_members) <= 1)
+			continue;
+
+		/*
+		 * A join EC needs to have multiple relids, so ignore cases with only
+		 * a single relid.
+		 *
+		 * XXX Could there be 0 relids? I don't think so. Or do we need to
+		 * check if (num != 2) instead?
+		 *
+		 * XXX If this simple check is not enough and we need to inspect ut we
+		 * need to inspect the members, we should probably leave this after
+		 * matching to FK attributes, if it doesn't match. The FK check is
+		 * likely cheaper
+		 */
+		if (bms_num_members(ec->ec_relids) == 1)
+			continue;
+
+		/* Is there a FK attribute referencing this EC? */
+		for (int i = 0; i < fkinfo->nkeys; i++)
+		{
+			if (fkinfo->eclass[i] == ec)
+			{
+				matched = true;
+				break;
+			}
+		}
+
+		/* found a join eclass not matched by the FK */
+		if (!matched)
+			return false;
+	}
+
+	/* all the join clauses seem to match (both rinfo and eclass) */
+	return true;
+}
+
+/*
+ * starjoin_match_to_foreign_key
+ *		Determine if the relation is joined through a FK constraint.
+ *
+ * The relation needs to be joined through a FK constraint, with it being on
+ * the PK side of the join. The FK must be matched completely (no columns
+ * missing in join clauses), and there must be no other join clauses.
+ *
+ * We already have a list of relevant foreign keys, and we use that info
+ * for selectivity estimation in get_foreign_key_join_selectivity(). And
+ * we're actually doing something quite similar here.
+ *
+ * XXX Do we need to worry about the join type, e.g. inner/outer joins,
+ * semi/anti? get_foreign_key_join_selectivity() does care about it, and
+ * ignores some of those cases. Maybe we should too?
+ */
+static bool
+starjoin_match_to_foreign_key(PlannerInfo *root, RelOptInfo *rel)
+{
+	ListCell   *lc;
+
+	/*
+	 * Check if there's a FK matching the join clauses.
+	 *
+	 * The match needs to be perfect from both sides. All join clauses need to
+	 * match the foreign key, and the whole foreign key needs to have a
+	 * matching clause. There must not be any extra join clauses.
+	 */
+	foreach(lc, root->fkey_list)
+	{
+		ForeignKeyOptInfo *fkinfo = (ForeignKeyOptInfo *) lfirst(lc);
+
+		/*
+		 * The foreign key is not relevant unless it references the rel on the
+		 * PK side.
+		 *
+		 * XXX If we want to support the "inverse" join (with smaller tables
+		 * referencing the main table) in the future, we'll probably need to
+		 * allow con_relid too.
+		 */
+		if (fkinfo->ref_relid != rel->relid)
+			continue;
+
+		/*
+		 * First, check that each FK attribute has a matching join clause.
+		 */
+		if (!starjoin_foreign_key_matched_by_clauses(root, rel, fkinfo))
+			continue;
+
+		/*
+		 * Each FK attribute has a join clause matching it. What about the
+		 * opposite direction? Do all join clauses match this FK? We need to
+		 * check both joininfo and equivalence classes (if the rel has
+		 * has_eclass_joins=true).
+		 */
+		if (!starjoin_clauses_matched_by_foreign_key(root, rel, fkinfo))
+			continue;
+
+		/* matched in both directions */
+		return true;
+	}
+
+	return false;
+}
+
+/*
+ * starjoin_is_dimension
+ *		Determine if a range table entry is a dimension in a starjoin.
+ *
+ * To be considered a dimension for the simplified join order search, the
+ * relation must be joined in a way that does not affect the cardinality
+ * of the join. And must be possible to postpone the join.
+ *
+ * We ensure that by checking a couple things:
+ *
+ * 1) The join clause(s) match a single FK. There has to be a single FK,
+ *    with each key covered by a join clause. There must be no extra join
+ *    clauses (which means the rel can be joined to a single other rel).
+ *
+ * 2) The FK side (in the fact table) should be marked as NOT NULL.
+ *
+ * 3) There must be no additional restrictions (WHERE conditions etc.).
+ *
+ * 4) The relation must not have a join order restriction, i.e. it has to
+ *    be possible to "postpone" the join.
+ *
+ * These three rules guarantee the join does not alter the cardinality, as
+ * each row in the fact table has exactly one match in the dimension.
+ *
+ * XXX For inner joins this works fine. For outer we may need to be smarter
+ * as outer joins imply ordering restrictions, violating (4) and so don't
+ * allow the simplified planning. I believe it should be possible to still
+ * postpone the join, if we're dealing with the last relation in the list
+ * (because then we're not really changing the order). Or maybe it needs
+ * to check varnullingrels? In any case, we need to keep this cheap,
+ * cheaper than the "full" join planning.
+ *
+ * XXX Could we relax (3) in some way, to allow filters on dimensions? The
+ * joins are independent (there are no clauses between dimensions), so the
+ * per-dimension selectivity could measures how much it "shrinks" the join.
+ * We could order the dimensions so that the most selective (close to 0.0)
+ * are joined first, to reduce the join cardinality soon. This could be
+ * extended with the "cost" of evaluating the join clause, to make the cost
+ * model more correct.
+ *
+ * In snowflake joins this would be more complicated, because dimensions
+ * can join to other (child) dimensions, but even there the clauses don't
+ * go between branches. So even there it should be possible.
+ *
+ * XXX With LEFT joins we could even allow baserestrictinfo on the rel, as
+ * that won't affect the cardinality (if the filter happens before the
+ * join, of course). But such queries are likely rather rare, because why
+ * would you filter before LEFT join?
+ *
+ * XXX Right now this uses has_join_restriction(), but maybe that's a bit
+ * too heavy-handed and have_join_order_restriction would be better? But
+ * that might be too early? Also, have_join_order_restriction is already
+ * exposed in paths.h, not static. Could have_relevant_joinclause help?
+ *
+ * XXX There's also join_is_legal() to check legality of joins, with
+ * LEFT/RIGHT joins, and IN/EXISTS clauses (see "Join Tree Construction"
+ * in README, circa line 188). It also looks-up the SpecialJoinInfo for
+ * the join. But it's probably too early for join_is_legal(), we don't
+ * have RelOptInfos for joins yet, just base relations.
+ *
+ * XXX Is it just the "skipping" that can break the order restrictions?
+ * Adding something to the list of dimensions keeps the order (at least
+ * with respect to the rels after it).
+ */
+static bool
+starjoin_is_dimension(PlannerInfo *root, RangeTblRef *rtr)
+{
+	Index		rti = rtr->rtindex;
+	RangeTblEntry *rte = root->simple_rte_array[rti];
+	RelOptInfo *rel = root->simple_rel_array[rti];
+
+	/* only plain relations can be dimensions (we need FK/PK join) */
+	if ((rte->rtekind != RTE_RELATION) ||
+		(rel->reloptkind != RELOPT_BASEREL))
+		return false;
+
+	/*
+	 * Does it have any conditions/restrictions that may affect the number of
+	 * rows matched? If yes, don't treat as dimension.
+	 *
+	 * Dimensions in a starjoin may have restrictions, but that means it'll
+	 * change cardinality of the joins (reduce it), so it may be better to
+	 * join it early. We leave it to the regular join order planning. The
+	 * expectation is that most dimensions won't have extra restrictions.
+	 *
+	 * XXX Should we check some other fields, like lateral references, and so
+	 * on? Or is that unnecessary? What about eclasses?
+	 */
+	if (rel->baserestrictinfo != NIL)
+		return false;
+
+	/*
+	 * Ignore relations with join restrictions. This requires the complete
+	 * join order search, this cheap heuristics is not enough.
+	 *
+	 * XXX This blocks the simplified planning for LEFT (or OUTER) joins,
+	 * because outer joins imply restrictions.
+	 */
+	if (has_join_restriction(root, rel))
+		return false;
+
+	/*
+	 * See if the join clause matches a foreign key. It should match a single
+	 * relation on the other side, and the dimension should be on the PK side.
+	 */
+	if (!starjoin_match_to_foreign_key(root, rel))
+		return false;
+
+	/*
+	 * XXX Maybe some additional checks here ...
+	 */
+
+	return true;
+}
+
+/*
+ * starjoin_adjust_joins
+ *		Adjust the jointree for starjoins, to simplify the join order search.
+ *
+ * Try to simplify the join search problem for starjoin-like joins, with
+ * joins over FK relationships. The dimensions can be joined in almost any
+ * order, which is about the worst case for the standard join order search,
+ * and can be close to factorial complexity. But all the different orders
+ * are equivalent, so all this work is wasted. So the simplified planning
+ * identifies dimensions, and joins them all at the end of each group (as
+ * determined by the join_collapse_limit earlier).
+ *
+ * Note: The definition of a dimension is a bit vague. See the comment at
+ * starjoin_is_dimension() for our definition.
+ *
+ * The join search for starjoin queries is surprisingly expensive, because
+ * there are very few join order restrictions. Consider a starjoin query
+ *
+ *  SELECT * FROM f
+ *     JOIN d1 ON (f.id1 = d1.id)
+ *     JOIN d2 ON (f.id2 = d2.id)
+ *     ...
+ *     JOIN d9 ON (f.id9 = d9.id)
+ *
+ * There are no clauses between the dimension tables (d#), which means those
+ * tables can be joined in almost arbitrary order. This means the standard
+ * join_order_search() would explore a N! possible join orders. It is not
+ * that bad in practice, as we split the problem by from_collapse_limit into
+ * a sequence of smaller problems, but even for the default of 8 relations
+ * it's quite expensive. This can be easily demonstrated by setting
+ * join_collapse_limit=1 for starjoin queries.
+ *
+ * We can significantly simplify the join order search for this type of
+ * queries, without too much risk of picking a much worse plans. It is
+ * however a trade off between how expensive we allow this to be, and how
+ * good the decisions will be. This can help only starjoins with multiple
+ * dimension tables, and we don't want to harm planning of other queries,
+ * so the basic "query shape" detection needs to be very cheap.
+ *
+ * If a perfect check is impossible or too expensive, it's better to end
+ * up with a cheap false negative (i.e. and not use the optimization),
+ * rather than risk regressions in other cases. Otherwise we might just
+ * as well use the regular join order search.
+ *
+ * The simplified join order search relies leverages the fact that joins
+ * of dimensions do not change the cardinality of the join, which means
+ * the relative order of those joins does not matter. All orders perform
+ * the same - we can pick an arbitrary of those orders, and "hardcode"
+ * it in the join tree before passing it to join_order_search().
+ *
+ * The join order is "hardcoded" by modifying the jointree, undoing some
+ * of the work performed by deconstruct_jointree earlier. That decomposed
+ * the original jointree into smaller "problems" depending on join type,
+ * join_collapse_limit and other details. We inspect those smaller lists,
+ * and selectively split them into smaller problems to force a join order.
+ * This may effectively undo some of the merging, which tries to construct
+ * problems with up to join_collapse_limit relations.
+ *
+ * For example, imagine a join problem with 8 rels - one fact table and
+ * then 7 dimensions, which we can represent a joinlist with 8 elements.
+ *
+ * (D7, D6, D5, D4, D3, D2, D1, F)
+ *
+ * Assuming all those joins meet the requirements (have a matching FK,
+ * don't affect the join cardinality, ...), then we can split this into
+ *
+ * (D7, (D6, (D5, (D4, (D3, (D2, (D1, F)))))))
+ *
+ * which is a nested joinlist, with only two elements on each level. That
+ * means there's no need for expensive join order search, there's only
+ * one way to join the relations (two, if we consider the relations may
+ * switch sides).
+ *
+ * The joinlist may already be nested, with multiple smaller join problems,
+ * similar to the example. Those are processed independently. We don't try
+ * to merge problems to build join_collapse_limit problems again. This is
+ * partially to keep it cheap/simple, but also so not change behavior for
+ * cases when people use join_collapse_limit to force a particular shape.
+ *
+ * Note: Ne never move relations between the "smaller problems", so this
+ * restricts what fraction of the join space we explore. So reducing the
+ * join_collapse_limit would improve the starjoin planning, but it may
+ * produce worse plans for other queries.
+ *
+ * The query may involve joins to additional (non-dimension) tables, and
+ * those may alter cardinality in either direction. In principle, it'd be
+ * best to first perform all the joins that reduce join size, then join all
+ * the dimensions, and finally perform joins that may increase the join
+ * size. Imagine a joinlist:
+ *
+ * (D1, D2, A, B, F)
+ *
+ * with fact F, dimensions D1 and D2, and non-dimensions A and B. If A
+ * increases cardinality, and B does not (or even reduces it), we could
+ * use this join tree:
+ *
+ * (A, (D2, (D1, (B, F))))
+ *
+ * For now we simply leave the dimension joins at the end, assuming
+ * that the earlier joins did not inflate the join too much.
+ *
+ * XXX Joins with cardinality increase don't seem very common, at least in
+ * regular starjoin queries. But maybe we could simply check if there are
+ * any such joins and disable this optimization? Is there a cheap way to
+ * identify when a join increases cardinality? I suppose we would need to
+ * calculate selectivity for join clauses? That might be too expensive,
+ * which goes against keeping this join search optimization very cheap.
+ *
+ * XXX A possible improvement would be to allow snowflake joins, i.e.
+ * queries with "recursive" dimensions. That would require a more complex
+ * logic, as a dimension would be allowed to join to other rels, as long
+ * as those are dimensions too. We'd need to be careful about preventing
+ * cycles, and about the order in which we join them.
+ *
+ * XXX Maybe we could invent a "join item" representing the dimensions,
+ * and pass it to join_order_search()? It'd expand the item into individual
+ * joins, and do the regular cardinality estimations etc. But it would
+ * only consider a single join order of the dimension. It would be trivial
+ * to disable the optimization if needed, I think - don't collapse the
+ * dimensions into the "group" join item. It would require changes to
+ * the generic join search, to be aware of the new item type.
+ */
+List *
+starjoin_adjust_joins(PlannerInfo *root, List *joinlist)
+{
+	ListCell   *lc;
+	List	   *newlist = NIL;
+	List	   *dimensions = NIL;
+	int			nlist = list_length(joinlist);
+
+	/* Do nothing if starjoin optimization not enabled. */
+	if (!enable_starjoin_join_search)
+		return joinlist;
+
+	/*
+	 * Do nothing if starjoin optimization not applicable.
+	 *
+	 * XXX It might seems we can skip this for lists with <= 2 items, but that
+	 * does not work, the elements may be nested list, and we need to descend
+	 * into those. So do what remove_useless_self_joins() does, and only bail
+	 * out for the simplest single-relation case (i.e. no joins).
+	 */
+	if (joinlist == NIL ||
+		(nlist == 1 && !IsA(linitial(joinlist), List)))
+		return joinlist;
+
+	/*
+	 * Process the current join problem - split the elements into dimensions
+	 * and non-dimensions. If there are dimensions, add them back at the end,
+	 * as small single-rel joins.
+	 *
+	 * The list may contain various types of elements. It may contain a list,
+	 * which means it's an independent join search problem and we need to
+	 * process it recursively. Or it may be RangeTblRef, in which case we need
+	 * to check if it's a dimension. Other types of elements are just added
+	 * back to the list as-is.
+	 *
+	 * XXX I think we need to be careful to keep the order of the list (for
+	 * the non-dimension entries). The join_search_one_level() relies on that
+	 * when handling join order restrictions.
+	 *
+	 * XXX It might be better to only create a new list if needed, i.e. once
+	 * we find the first dimension. So that non-starjoin queries don't pay for
+	 * something they don't need. A mutable iterator might be a way, but I'm
+	 * not sure how expensive this really is.
+	 */
+	foreach(lc, joinlist)
+	{
+		Node	   *item = (Node *) lfirst(lc);
+
+		/* a separate join search problem, handle it recursively */
+		if (IsA(item, List))
+		{
+			newlist = lappend(newlist,
+							  starjoin_adjust_joins(root, (List *) item));
+			continue;
+		}
+
+		/*
+		 * If it's not a List, it has to be a RangeTblRef - jinlists can't
+		 * contain any other elements (see make_rel_from_joinlist).
+		 */
+		Assert(IsA(item, RangeTblRef));
+
+		/*
+		 * An entry representing a baserel. If it's a dimension, save it in a
+		 * separate list, and we'll add it at the "top" of the join at the
+		 * end. Otherwise add it to the list just like other elements.
+		 *
+		 * We do this only when the joinlist has at least 3 items. For fewer
+		 * rels the optimization does not matter, there's only a single join
+		 * order anyway. That only skips the optimization on this level - we
+		 * still do the recursion, and that might hit a larger join problem.
+		 *
+		 * XXX We might have a new GUC to customize the cutoff limit, but for
+		 * now it seems good enough to do it whenever applicable. If we find
+		 * it's not worth it for less than N rels, we can add it later.
+		 */
+		if ((nlist >= 3) &&
+			starjoin_is_dimension(root, (RangeTblRef *) item))
+		{
+			dimensions = lappend(dimensions, item);
+			continue;
+		}
+
+		/* not a dimension, add it to the list directly */
+		newlist = lappend(newlist, item);
+	}
+
+	/*
+	 * If we found some dimensions, add them to the join tree one by one. The
+	 * exact order does not matter, so we add them in the order we found them
+	 * in the original list.
+	 *
+	 * We need to add them by creating smaller 2-element lists, with the rest
+	 * of the list being a sub-problem and then adding the dimension table.
+	 * This is how we force the desired join order.
+	 */
+	foreach(lc, dimensions)
+	{
+		newlist = list_make2(newlist, lfirst(lc));
+	}
+
+	return newlist;
+}
diff --git a/src/backend/optimizer/plan/planmain.c b/src/backend/optimizer/plan/planmain.c
index eefc486a566..58278dbc94f 100644
--- a/src/backend/optimizer/plan/planmain.c
+++ b/src/backend/optimizer/plan/planmain.c
@@ -291,6 +291,13 @@ query_planner(PlannerInfo *root,
 	 */
 	distribute_row_identity_vars(root);
 
+	/*
+	 * Try to simplify the join search problem for starjoin-like joins. This
+	 * step relies on info about FK relationships, so we can't do it till
+	 * after match_foreign_keys_to_quals().
+	 */
+	joinlist = starjoin_adjust_joins(root, joinlist);
+
 	/*
 	 * Ready to do the primary planning.
 	 */
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index 1128167c025..d5b976b2e39 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -968,6 +968,13 @@
   boot_val => 'true',
 },
 
+{ name => 'enable_starjoin_join_search', type => 'bool', context => 'PGC_USERSET', group => 'QUERY_TUNING_METHOD',
+  short_desc => 'Enables simplified join order planning for starjoins.',
+  flags => 'GUC_EXPLAIN',
+  variable => 'enable_starjoin_join_search',
+  boot_val => 'true',
+},
+
 { name => 'enable_tidscan', type => 'bool', context => 'PGC_USERSET', group => 'QUERY_TUNING_METHOD',
   short_desc => 'Enables the planner\'s use of TID scan plans.',
   flags => 'GUC_EXPLAIN',
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index f62b61967ef..458cd74c262 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -424,6 +424,7 @@
 #enable_presorted_aggregate = on
 #enable_seqscan = on
 #enable_sort = on
+#enable_starjoin_join_search = on
 #enable_tidscan = on
 #enable_group_by_reordering = on
 #enable_distinct_reordering = on
diff --git a/src/include/optimizer/paths.h b/src/include/optimizer/paths.h
index f6a62df0b43..8d3c44854cd 100644
--- a/src/include/optimizer/paths.h
+++ b/src/include/optimizer/paths.h
@@ -108,6 +108,7 @@ extern RelOptInfo *make_join_rel(PlannerInfo *root,
 extern Relids add_outer_joins_to_relids(PlannerInfo *root, Relids input_relids,
 										SpecialJoinInfo *sjinfo,
 										List **pushed_down_joins);
+extern bool has_join_restriction(PlannerInfo *root, RelOptInfo *rel);
 extern bool have_join_order_restriction(PlannerInfo *root,
 										RelOptInfo *rel1, RelOptInfo *rel2);
 extern void mark_dummy_rel(RelOptInfo *rel);
diff --git a/src/include/optimizer/planmain.h b/src/include/optimizer/planmain.h
index 00addf15992..c30ac3a5754 100644
--- a/src/include/optimizer/planmain.h
+++ b/src/include/optimizer/planmain.h
@@ -21,6 +21,7 @@
 #define DEFAULT_CURSOR_TUPLE_FRACTION 0.1
 extern PGDLLIMPORT double cursor_tuple_fraction;
 extern PGDLLIMPORT bool enable_self_join_elimination;
+extern PGDLLIMPORT bool enable_starjoin_join_search;
 
 /* query_planner callback to compute query_pathkeys */
 typedef void (*query_pathkeys_callback) (PlannerInfo *root, void *extra);
@@ -120,6 +121,7 @@ extern bool innerrel_is_unique_ext(PlannerInfo *root, Relids joinrelids,
 								   JoinType jointype, List *restrictlist,
 								   bool force_cache, List **extra_clauses);
 extern List *remove_useless_self_joins(PlannerInfo *root, List *joinlist);
+extern List *starjoin_adjust_joins(PlannerInfo *root, List *joinlist);
 
 /*
  * prototypes for plan/setrefs.c
diff --git a/src/test/regress/expected/sysviews.out b/src/test/regress/expected/sysviews.out
index 3b37fafa65b..e9762da9937 100644
--- a/src/test/regress/expected/sysviews.out
+++ b/src/test/regress/expected/sysviews.out
@@ -172,8 +172,9 @@ select name, setting from pg_settings where name like 'enable%';
  enable_self_join_elimination   | on
  enable_seqscan                 | on
  enable_sort                    | on
+ enable_starjoin_join_search    | on
  enable_tidscan                 | on
-(25 rows)
+(26 rows)
 
 -- There are always wait event descriptions for various types.  InjectionPoint
 -- may be present or absent, depending on history since last postmaster start.
-- 
2.51.1