diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out index e90289e4ab1..2d2ea9d1019 100644 --- a/contrib/postgres_fdw/expected/postgres_fdw.out +++ b/contrib/postgres_fdw/expected/postgres_fdw.out @@ -2432,6 +2432,92 @@ SELECT t1."C 1" FROM "S 1"."T 1" t1, LATERAL (SELECT DISTINCT t2.c1, t3.c1 FROM 1 (10 rows) +-- Parameterized foreign join: a join between two foreign tables that carries a +-- lateral reference to an outer (local) relation is implemented as a +-- parameterized ForeignPath, with the outer values sent to the remote server +-- as query parameters. The RIGHT JOIN keeps the lateral reference from being +-- flattened into an ordinary join clause, so the inner foreign join really is +-- parameterized. (Derived from a case in src/test/regress/sql/join.sql.) +CREATE TABLE loc_pfj (f1 int); +INSERT INTO loc_pfj VALUES (1), (3), (5); +CREATE TABLE base_pfj8 (q1 bigint, q2 bigint); +INSERT INTO base_pfj8 VALUES (1, 1); +CREATE TABLE base_pfj4 (f1 int); +INSERT INTO base_pfj4 VALUES (1), (3), (5); +CREATE FOREIGN TABLE ft_pfj8 (q1 bigint, q2 bigint) + SERVER loopback OPTIONS (table_name 'base_pfj8', use_remote_estimate 'true'); +CREATE FOREIGN TABLE ft_pfj4 (f1 int) + SERVER loopback OPTIONS (table_name 'base_pfj4', use_remote_estimate 'true'); +ANALYZE base_pfj8; +ANALYZE base_pfj4; +-- The inner foreign join (ft_pfj8 x ft_pfj4) is parameterized by loc_pfj; +-- note the parameter placeholder ($1) in the remote SQL. +EXPLAIN (VERBOSE, COSTS OFF) +SELECT o.f1, ss.x FROM loc_pfj o, + LATERAL (SELECT 1 AS x + FROM (SELECT o.f1 AS lat, i2.f1 AS locv FROM ft_pfj8 i1, ft_pfj4 i2) ss1 + RIGHT JOIN ft_pfj4 i3 ON (i3.f1 > 1) + WHERE ss1.locv = ss1.lat) ss + ORDER BY o.f1; + QUERY PLAN +------------------------------------------------------------------------------------------------------------------------------- + Sort + Output: o.f1, 1 + Sort Key: o.f1 + -> Nested Loop + Output: o.f1, 1 + -> Nested Loop + Output: o.f1 + -> Seq Scan on public.loc_pfj o + Output: o.f1 + -> Foreign Scan + Filter: (i2.f1 = o.f1) + Relations: (public.ft_pfj8 i1) INNER JOIN (public.ft_pfj4 i2) + Remote SQL: SELECT r7.f1, $1::integer FROM (public.base_pfj8 r6 INNER JOIN public.base_pfj4 r7 ON (TRUE)) + -> Materialize + -> Foreign Scan on public.ft_pfj4 i3 + Remote SQL: SELECT NULL FROM public.base_pfj4 WHERE ((f1 > 1)) +(16 rows) + +SELECT o.f1, ss.x FROM loc_pfj o, + LATERAL (SELECT 1 AS x + FROM (SELECT o.f1 AS lat, i2.f1 AS locv FROM ft_pfj8 i1, ft_pfj4 i2) ss1 + RIGHT JOIN ft_pfj4 i3 ON (i3.f1 > 1) + WHERE ss1.locv = ss1.lat) ss + ORDER BY o.f1; + f1 | x +----+--- + 1 | 1 + 1 | 1 + 3 | 1 + 3 | 1 + 5 | 1 + 5 | 1 +(6 rows) + +-- The same query over the underlying local tables, as a correctness oracle: +-- it must return exactly the same rows as the pushed-down foreign join above. +SELECT o.f1, ss.x FROM loc_pfj o, + LATERAL (SELECT 1 AS x + FROM (SELECT o.f1 AS lat, i2.f1 AS locv FROM base_pfj8 i1, base_pfj4 i2) ss1 + RIGHT JOIN base_pfj4 i3 ON (i3.f1 > 1) + WHERE ss1.locv = ss1.lat) ss + ORDER BY o.f1; + f1 | x +----+--- + 1 | 1 + 1 | 1 + 3 | 1 + 3 | 1 + 5 | 1 + 5 | 1 +(6 rows) + +DROP FOREIGN TABLE ft_pfj8; +DROP FOREIGN TABLE ft_pfj4; +DROP TABLE base_pfj8; +DROP TABLE base_pfj4; +DROP TABLE loc_pfj; -- join with pseudoconstant quals EXPLAIN (VERBOSE, COSTS OFF) SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1 AND CURRENT_USER = SESSION_USER) ORDER BY t1.c3, t1.c1 OFFSET 100 LIMIT 10; diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c index 0a589f8db74..c25b7d3b18b 100644 --- a/contrib/postgres_fdw/postgres_fdw.c +++ b/contrib/postgres_fdw/postgres_fdw.c @@ -1534,18 +1534,34 @@ postgresGetForeignPlan(PlannerInfo *root, scan_relid = 0; /* - * For a join rel, baserestrictinfo is NIL and we are not considering - * parameterization right now, so there should be no scan_clauses for - * a joinrel or an upper rel either. + * For a join rel, the conditions to apply are obtained from the + * fdw_private structure (see foreign_join_ok()). For an upper rel + * there are none there and scan_clauses is empty. */ - Assert(!scan_clauses); + remote_exprs = extract_actual_clauses(fpinfo->remote_conds, false); + local_exprs = extract_actual_clauses(fpinfo->local_conds, false); /* - * Instead we get the conditions to apply from the fdw_private - * structure. + * A parameterized foreign join additionally receives, via + * scan_clauses, the join clauses that were moved into this path + * because of the parameterization (see + * get_joinrel_parampathinfo_pushdown()). Enforce the shippable ones + * remotely as parameters, and any others locally. (Upper rels are + * never parameterized, so this loop is a no-op for them.) */ - remote_exprs = extract_actual_clauses(fpinfo->remote_conds, false); - local_exprs = extract_actual_clauses(fpinfo->local_conds, false); + foreach(lc, scan_clauses) + { + RestrictInfo *rinfo = lfirst_node(RestrictInfo, lc); + + /* Ignore any pseudoconstants, they're dealt with elsewhere */ + if (rinfo->pseudoconstant) + continue; + + if (is_foreign_expr(root, foreignrel, rinfo->clause)) + remote_exprs = lappend(remote_exprs, rinfo->clause); + else + local_exprs = lappend(local_exprs, rinfo->clause); + } /* * We leave fdw_recheck_quals empty in this case, since we never need @@ -7204,6 +7220,55 @@ merge_fdw_options(PgFdwRelationInfo *fpinfo, } } +/* + * get_foreign_join_param_conds + * Identify the join clauses for the given join relation that can be + * pushed to the remote server for a path parameterized by + * required_outer, and return them as a list of RestrictInfos. + * + * These are the clauses that are movable into this join given the + * parameterization and are safe to send to the remote server. We consider + * both "generic" join clauses from the joininfo list and clauses derived from + * EquivalenceClasses (the latter cover, for example, the equality conditions + * introduced by a LATERAL reference). + */ +static List * +get_foreign_join_param_conds(PlannerInfo *root, RelOptInfo *joinrel, + Relids required_outer) +{ + List *result = NIL; + Relids join_and_req = bms_union(joinrel->relids, required_outer); + List *ec_clauses; + ListCell *lc; + + /* Generic join clauses movable into this join. */ + foreach(lc, joinrel->joininfo) + { + RestrictInfo *rinfo = lfirst_node(RestrictInfo, lc); + + if (!join_clause_is_movable_into(rinfo, joinrel->relids, join_and_req)) + continue; + if (!is_foreign_expr(root, joinrel, rinfo->clause)) + continue; + result = lappend(result, rinfo); + } + + /* Clauses generated by EquivalenceClasses for this parameterization. */ + ec_clauses = generate_join_implied_equalities(root, join_and_req, + required_outer, joinrel, + NULL); + foreach(lc, ec_clauses) + { + RestrictInfo *rinfo = lfirst_node(RestrictInfo, lc); + + if (!is_foreign_expr(root, joinrel, rinfo->clause)) + continue; + result = lappend(result, rinfo); + } + + return result; +} + /* * postgresGetForeignJoinPaths * Add possible ForeignPath to joinrel, if join is safe to push down. @@ -7223,6 +7288,7 @@ postgresGetForeignJoinPaths(PlannerInfo *root, int disabled_nodes; Cost startup_cost; Cost total_cost; + List *joinrestrictinfo; Path *epq_path; /* Path to create plan to be executed when * EvalPlanQual gets triggered. */ @@ -7233,10 +7299,16 @@ postgresGetForeignJoinPaths(PlannerInfo *root, return; /* - * This code does not work for joins with lateral references, since those - * must have parameterized paths, which we don't generate yet. + * A join relation with lateral references requires parameterized paths. + * We can build those for plain SELECTs, but not yet for the cases that + * need an EvalPlanQual recheck (UPDATE/DELETE or a locking clause), + * because GetExistingLocalJoinPath() only finds unparameterized local + * join paths. So for those cases we still decline to push the join down. */ - if (!bms_is_empty(joinrel->lateral_relids)) + if (!bms_is_empty(joinrel->lateral_relids) && + (root->parse->commandType == CMD_DELETE || + root->parse->commandType == CMD_UPDATE || + root->rowMarks)) return; /* @@ -7324,7 +7396,19 @@ postgresGetForeignJoinPaths(PlannerInfo *root, /* * Create a new join path and add it to the joinrel which represents a * join between foreign tables. + * + * If the join relation has lateral references, the path is parameterized + * by the lateral rels. Push the join clauses that reference those outer + * rels down to the remote server as well, so they are enforced remotely + * (as parameters) rather than locally. */ + joinrestrictinfo = extra->restrictlist; + if (!bms_is_empty(joinrel->lateral_relids)) + joinrestrictinfo = + list_concat(list_copy(extra->restrictlist), + get_foreign_join_param_conds(root, joinrel, + joinrel->lateral_relids)); + joinpath = create_foreign_join_path(root, joinrel, NULL, /* default pathtarget */ @@ -7335,7 +7419,7 @@ postgresGetForeignJoinPaths(PlannerInfo *root, NIL, /* no pathkeys */ joinrel->lateral_relids, epq_path, - extra->restrictlist, + joinrestrictinfo, NIL); /* no fdw_private */ /* Add generated path into joinrel by add_path(). */ @@ -7343,9 +7427,7 @@ postgresGetForeignJoinPaths(PlannerInfo *root, /* Consider pathkeys for the join relation */ add_paths_with_pathkeys_for_rel(root, joinrel, epq_path, - extra->restrictlist); - - /* XXX Consider parameterized paths for the join relation */ + joinrestrictinfo); } /* diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql index dfc58beb0d2..d09a84a8432 100644 --- a/contrib/postgres_fdw/sql/postgres_fdw.sql +++ b/contrib/postgres_fdw/sql/postgres_fdw.sql @@ -708,6 +708,54 @@ SELECT t1c1, avg(t1c1 + t2c1) FROM (SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 EXPLAIN (VERBOSE, COSTS OFF) SELECT t1."C 1" FROM "S 1"."T 1" t1, LATERAL (SELECT DISTINCT t2.c1, t3.c1 FROM ft1 t2, ft2 t3 WHERE t2.c1 = t3.c1 AND t2.c2 = t1.c2) q ORDER BY t1."C 1" OFFSET 10 LIMIT 10; SELECT t1."C 1" FROM "S 1"."T 1" t1, LATERAL (SELECT DISTINCT t2.c1, t3.c1 FROM ft1 t2, ft2 t3 WHERE t2.c1 = t3.c1 AND t2.c2 = t1.c2) q ORDER BY t1."C 1" OFFSET 10 LIMIT 10; + +-- Parameterized foreign join: a join between two foreign tables that carries a +-- lateral reference to an outer (local) relation is implemented as a +-- parameterized ForeignPath, with the outer values sent to the remote server +-- as query parameters. The RIGHT JOIN keeps the lateral reference from being +-- flattened into an ordinary join clause, so the inner foreign join really is +-- parameterized. (Derived from a case in src/test/regress/sql/join.sql.) +CREATE TABLE loc_pfj (f1 int); +INSERT INTO loc_pfj VALUES (1), (3), (5); +CREATE TABLE base_pfj8 (q1 bigint, q2 bigint); +INSERT INTO base_pfj8 VALUES (1, 1); +CREATE TABLE base_pfj4 (f1 int); +INSERT INTO base_pfj4 VALUES (1), (3), (5); +CREATE FOREIGN TABLE ft_pfj8 (q1 bigint, q2 bigint) + SERVER loopback OPTIONS (table_name 'base_pfj8', use_remote_estimate 'true'); +CREATE FOREIGN TABLE ft_pfj4 (f1 int) + SERVER loopback OPTIONS (table_name 'base_pfj4', use_remote_estimate 'true'); +ANALYZE base_pfj8; +ANALYZE base_pfj4; +-- The inner foreign join (ft_pfj8 x ft_pfj4) is parameterized by loc_pfj; +-- note the parameter placeholder ($1) in the remote SQL. +EXPLAIN (VERBOSE, COSTS OFF) +SELECT o.f1, ss.x FROM loc_pfj o, + LATERAL (SELECT 1 AS x + FROM (SELECT o.f1 AS lat, i2.f1 AS locv FROM ft_pfj8 i1, ft_pfj4 i2) ss1 + RIGHT JOIN ft_pfj4 i3 ON (i3.f1 > 1) + WHERE ss1.locv = ss1.lat) ss + ORDER BY o.f1; +SELECT o.f1, ss.x FROM loc_pfj o, + LATERAL (SELECT 1 AS x + FROM (SELECT o.f1 AS lat, i2.f1 AS locv FROM ft_pfj8 i1, ft_pfj4 i2) ss1 + RIGHT JOIN ft_pfj4 i3 ON (i3.f1 > 1) + WHERE ss1.locv = ss1.lat) ss + ORDER BY o.f1; +-- The same query over the underlying local tables, as a correctness oracle: +-- it must return exactly the same rows as the pushed-down foreign join above. +SELECT o.f1, ss.x FROM loc_pfj o, + LATERAL (SELECT 1 AS x + FROM (SELECT o.f1 AS lat, i2.f1 AS locv FROM base_pfj8 i1, base_pfj4 i2) ss1 + RIGHT JOIN base_pfj4 i3 ON (i3.f1 > 1) + WHERE ss1.locv = ss1.lat) ss + ORDER BY o.f1; +DROP FOREIGN TABLE ft_pfj8; +DROP FOREIGN TABLE ft_pfj4; +DROP TABLE base_pfj8; +DROP TABLE base_pfj4; +DROP TABLE loc_pfj; + -- join with pseudoconstant quals EXPLAIN (VERBOSE, COSTS OFF) SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1 AND CURRENT_USER = SESSION_USER) ORDER BY t1.c3, t1.c1 OFFSET 100 LIMIT 10; diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c index 73518c8f870..c70fb83f7fe 100644 --- a/src/backend/optimizer/util/pathnode.c +++ b/src/backend/optimizer/util/pathnode.c @@ -2171,6 +2171,11 @@ create_foreignscan_path(PlannerInfo *root, RelOptInfo *rel, * We make the FDW supply all fields of the path, since we do not have any way * to calculate them in core. However, there is a usually-sane default for * the pathtarget (rel->reltarget), so we let a NULL for "target" select that. + * + * The path may be parameterized: pass the required outer rels in + * required_outer and the RestrictInfos the path enforces (the join's own + * clauses plus any moved into the path by the parameterization) in + * fdw_restrictinfo. We build the ParamPathInfo from those. */ ForeignPath * create_foreign_join_path(PlannerInfo *root, RelOptInfo *rel, @@ -2186,19 +2191,21 @@ create_foreign_join_path(PlannerInfo *root, RelOptInfo *rel, ForeignPath *pathnode = makeNode(ForeignPath); /* - * We should use get_joinrel_parampathinfo to handle parameterized paths, - * but the API of this function doesn't support it, and existing - * extensions aren't yet trying to build such paths anyway. For the - * moment just throw an error if someone tries it; eventually we should - * revisit this. + * Build a ParamPathInfo if this is a parameterized path, i.e. one that + * has required outer rels (for instance a foreign join with LATERAL + * references, or one that enforces join clauses referencing outer + * relations). The FDW tells us which clauses the path enforces via + * fdw_restrictinfo; we record their serial numbers in the ParamPathInfo + * so that get_param_path_clause_serials() reports them correctly and the + * planner doesn't re-check them at a parent join. For an unparameterized + * path this returns NULL, as before. */ - if (!bms_is_empty(required_outer) || !bms_is_empty(rel->lateral_relids)) - elog(ERROR, "parameterized foreign joins are not supported yet"); - pathnode->path.pathtype = T_ForeignScan; pathnode->path.parent = rel; pathnode->path.pathtarget = target ? target : rel->reltarget; - pathnode->path.param_info = NULL; /* XXX see above */ + pathnode->path.param_info = + get_joinrel_parampathinfo_pushdown(rel, required_outer, + fdw_restrictinfo, rows); pathnode->path.parallel_aware = false; pathnode->path.parallel_safe = rel->consider_parallel; pathnode->path.parallel_workers = 0; diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c index 3fc2c2f71d0..b49ced66ba7 100644 --- a/src/backend/optimizer/util/relnode.c +++ b/src/backend/optimizer/util/relnode.c @@ -2001,6 +2001,108 @@ get_joinrel_parampathinfo(PlannerInfo *root, RelOptInfo *joinrel, return ppi; } +/* + * get_joinrel_parampathinfo_pushdown + * Get/build the ParamPathInfo for a parameterized join path that an FDW + * or custom scan provider computes as a whole (for example, a foreign + * join pushed down to a remote server). + * + * Unlike get_joinrel_parampathinfo(), the caller does not have a pair of + * input paths describing how the join is formed. Instead it supplies the + * parameterization (required_outer), the estimated rowcount, and the list of + * RestrictInfos that the path enforces internally (restrict_clauses). That + * list normally consists of the join's own restriction clauses plus any join + * clauses that were moved into this path because of the parameterization. + * + * The restrict_clauses list is the full set of clauses the path enforces: + * the join's own restriction clauses plus any join clauses moved into the + * path because of the parameterization. From it we pick out the "moved" + * clauses -- those referencing required_outer -- and record them in both: + * + * ppi_clauses: so that create_foreignscan_plan() forwards them to the FDW's + * GetForeignPlan callback as scan_clauses (like it does for base rels), + * letting the FDW enforce them remotely as parameters. Note this differs + * from get_joinrel_parampathinfo(), which leaves ppi_clauses NIL because a + * locally-formed join's relevant clauses vary with the input-path pair; a + * pushed-down join computes the whole thing at once, so the set is fixed. + * + * ppi_serials: so that get_param_path_clause_serials() reports them for such + * a path (which falls through to the ppi_serials shortcut, being neither a + * join Path nor an Append/base Path), letting the planner avoid + * re-checking those clauses at a parent join. + * + * The join's own clauses are not put in ppi_clauses/ppi_serials: they are + * fully contained in the joinrel and can't be re-applied at a parent join, + * and the FDW enforces them from its own saved state, not via scan_clauses. + * + * As with the other get_*_parampathinfo functions, we cache one ParamPathInfo + * per parameterization on the rel. If one already exists (for instance one + * built by get_joinrel_parampathinfo() for a competing local join path, which + * leaves ppi_clauses/ppi_serials empty), we reuse it to keep the rowcount + * estimate consistent, but make sure its ppi_clauses/ppi_serials cover the + * clauses this path enforces. + */ +ParamPathInfo * +get_joinrel_parampathinfo_pushdown(RelOptInfo *joinrel, + Relids required_outer, + List *restrict_clauses, + double rows) +{ + ParamPathInfo *ppi; + List *pclauses; + Bitmapset *pserials; + ListCell *lc; + + /* If rel has LATERAL refs, every path for it should account for them */ + Assert(bms_is_subset(joinrel->lateral_relids, required_outer)); + + /* Unparameterized paths have no ParamPathInfo */ + if (bms_is_empty(required_outer)) + return NULL; + + Assert(!bms_overlap(joinrel->relids, required_outer)); + + /* + * Pick out the moved-in clauses (those referencing the outer rels) and + * compute their serial numbers. The join's own clauses reference only + * rels within the joinrel, so they don't overlap required_outer. + */ + pclauses = NIL; + pserials = NULL; + foreach(lc, restrict_clauses) + { + RestrictInfo *rinfo = lfirst_node(RestrictInfo, lc); + + if (!bms_overlap(rinfo->clause_relids, required_outer)) + continue; + pclauses = lappend(pclauses, rinfo); + pserials = bms_add_member(pserials, rinfo->rinfo_serial); + } + + /* + * Reuse an existing PPI for this parameterization if we have one, so that + * the rowcount estimate stays consistent across all paths, but make sure + * its ppi_clauses/ppi_serials cover the clauses this path enforces. + */ + if ((ppi = find_param_path_info(joinrel, required_outer))) + { + if (ppi->ppi_clauses == NIL) + ppi->ppi_clauses = pclauses; + ppi->ppi_serials = bms_add_members(ppi->ppi_serials, pserials); + return ppi; + } + + /* Else build a new ParamPathInfo */ + ppi = makeNode(ParamPathInfo); + ppi->ppi_req_outer = required_outer; + ppi->ppi_rows = rows; + ppi->ppi_clauses = pclauses; + ppi->ppi_serials = pserials; + joinrel->ppilist = lappend(joinrel->ppilist, ppi); + + return ppi; +} + /* * get_appendrel_parampathinfo * Get the ParamPathInfo for a parameterized path for an append relation. diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h index e8db321f92b..c99d97ccea4 100644 --- a/src/include/optimizer/pathnode.h +++ b/src/include/optimizer/pathnode.h @@ -374,6 +374,10 @@ extern ParamPathInfo *get_joinrel_parampathinfo(PlannerInfo *root, SpecialJoinInfo *sjinfo, Relids required_outer, List **restrict_clauses); +extern ParamPathInfo *get_joinrel_parampathinfo_pushdown(RelOptInfo *joinrel, + Relids required_outer, + List *restrict_clauses, + double rows); extern ParamPathInfo *get_appendrel_parampathinfo(RelOptInfo *appendrel, Relids required_outer); extern ParamPathInfo *find_param_path_info(RelOptInfo *rel,