1919
2020import org .apache .doris .nereids .trees .expressions .Slot ;
2121import org .apache .doris .nereids .trees .plans .Plan ;
22+ import org .apache .doris .nereids .trees .plans .logical .LogicalAggregate ;
23+ import org .apache .doris .nereids .trees .plans .logical .LogicalJoin ;
24+ import org .apache .doris .nereids .trees .plans .physical .PhysicalHashJoin ;
25+ import org .apache .doris .nereids .trees .plans .physical .PhysicalPlan ;
2226import org .apache .doris .nereids .util .PlanChecker ;
2327import org .apache .doris .utframe .TestWithFeService ;
2428
2731import org .junit .jupiter .api .Test ;
2832
2933import java .util .Set ;
34+ import java .util .function .Predicate ;
3035
3136class FdTest extends TestWithFeService {
3237 @ Override
@@ -46,6 +51,13 @@ protected void runBeforeAll() throws Exception {
4651 + "UNIQUE KEY(id)\n "
4752 + "distributed by hash(id) buckets 10\n "
4853 + "properties('replication_num' = '1');" );
54+ createTable ("create table test.nullable_uni (\n "
55+ + "id int,\n "
56+ + "id2 int not null,\n "
57+ + "name varchar(128) not null)\n "
58+ + "UNIQUE KEY(id)\n "
59+ + "distributed by hash(id) buckets 10\n "
60+ + "properties('replication_num' = '1');" );
4961 connectContext .setDatabase ("test" );
5062 connectContext .getSessionVariable ().setDisableNereidsRules ("PRUNE_EMPTY_PARTITION" );
5163 }
@@ -147,38 +159,139 @@ void testJoin() {
147159 Assertions .assertTrue (plan .getLogicalProperties ().getTrait ()
148160 .isDependent (ImmutableSet .of (plan .getOutput ().get (1 )), ImmutableSet .of (plan .getOutput ().get (2 ))));
149161
150- // foj
162+ // foj: both sides nullable — keep FDs with NOT NULL determinants
151163 plan = PlanChecker .from (connectContext )
152164 .analyze ("select t1.id, t1.id2, t2.id, t2.id2 "
153165 + "from uni as t1 full outer join uni as t2 on t1.id2 = t2.id2" )
154166 .rewrite ()
155167 .getPlan ();
168+ // t1.id is NOT NULL, so {t1.id} -> {t1.id2} survives null extension
156169 Assertions .assertTrue (plan .getLogicalProperties ().getTrait ()
157170 .isDependent (ImmutableSet .of (plan .getOutput ().get (0 )), ImmutableSet .of (plan .getOutput ().get (1 ))));
171+ // t2.id is NOT NULL, so {t2.id} -> {t2.id2} survives null extension
158172 Assertions .assertTrue (plan .getLogicalProperties ().getTrait ()
159173 .isDependent (ImmutableSet .of (plan .getOutput ().get (2 )), ImmutableSet .of (plan .getOutput ().get (3 ))));
160174
161- // loj
175+ // loj: left side preserved, right side nullable — only NOT NULL-determinant FDs from right propagate
162176 plan = PlanChecker .from (connectContext )
163177 .analyze ("select t1.id, t1.id2, t2.id, t2.id2 "
164178 + "from uni as t1 left outer join uni as t2 on t1.id2 = t2.id2" )
165179 .rewrite ()
166180 .getPlan ();
181+ // t1.id is NOT NULL, left side always preserved
167182 Assertions .assertTrue (plan .getLogicalProperties ().getTrait ()
168183 .isDependent (ImmutableSet .of (plan .getOutput ().get (0 )), ImmutableSet .of (plan .getOutput ().get (1 ))));
184+ // t2.id is NOT NULL, so {t2.id} -> {t2.id2} survives null extension
169185 Assertions .assertTrue (plan .getLogicalProperties ().getTrait ()
170186 .isDependent (ImmutableSet .of (plan .getOutput ().get (2 )), ImmutableSet .of (plan .getOutput ().get (3 ))));
171187
172- // roj
188+ // roj: right side preserved, left side nullable — only NOT NULL-determinant FDs from left propagate
173189 plan = PlanChecker .from (connectContext )
174190 .analyze ("select t1.id, t1.id2, t2.id, t2.id2 "
175191 + "from uni as t1 right outer join uni as t2 on t1.id2 = t2.id2" )
176192 .rewrite ()
177193 .getPlan ();
194+ // t1.id is NOT NULL, so {t1.id} -> {t1.id2} survives null extension
178195 Assertions .assertTrue (plan .getLogicalProperties ().getTrait ()
179196 .isDependent (ImmutableSet .of (plan .getOutput ().get (0 )), ImmutableSet .of (plan .getOutput ().get (1 ))));
197+ // t2.id is NOT NULL, right side always preserved
180198 Assertions .assertTrue (plan .getLogicalProperties ().getTrait ()
181199 .isDependent (ImmutableSet .of (plan .getOutput ().get (2 )), ImmutableSet .of (plan .getOutput ().get (3 ))));
200+
201+ // loj with nullable determinant: FD should be dropped
202+ plan = PlanChecker .from (connectContext )
203+ .analyze ("select t1.id, t1.id2, t2.id, t2.id2 "
204+ + "from uni as t1 left outer join nullable_uni as t2 on t1.id2 = t2.id2" )
205+ .rewrite ()
206+ .getPlan ();
207+ // t1 side preserved
208+ Assertions .assertTrue (plan .getLogicalProperties ().getTrait ()
209+ .isDependent (ImmutableSet .of (plan .getOutput ().get (0 )), ImmutableSet .of (plan .getOutput ().get (1 ))));
210+ // t2.id is nullable, so {t2.id} -> {t2.id2} should be dropped
211+ Assertions .assertFalse (plan .getLogicalProperties ().getTrait ()
212+ .isDependent (ImmutableSet .of (plan .getOutput ().get (2 )), ImmutableSet .of (plan .getOutput ().get (3 ))));
213+
214+ // foj with nullable determinant on one side
215+ plan = PlanChecker .from (connectContext )
216+ .analyze ("select t1.id, t1.id2, t2.id, t2.id2 "
217+ + "from uni as t1 full outer join nullable_uni as t2 on t1.id2 = t2.id2" )
218+ .rewrite ()
219+ .getPlan ();
220+ // t1.id is NOT NULL, so {t1.id} -> {t1.id2} survives
221+ Assertions .assertTrue (plan .getLogicalProperties ().getTrait ()
222+ .isDependent (ImmutableSet .of (plan .getOutput ().get (0 )), ImmutableSet .of (plan .getOutput ().get (1 ))));
223+ // t2.id is nullable, so {t2.id} -> {t2.id2} should be dropped
224+ Assertions .assertFalse (plan .getLogicalProperties ().getTrait ()
225+ .isDependent (ImmutableSet .of (plan .getOutput ().get (2 )), ImmutableSet .of (plan .getOutput ().get (3 ))));
226+ }
227+
228+ @ Test
229+ void testNestedOuterJoinNullableDeterminant () {
230+ // Reduced failing tree from review "Check determinant nullability against the current child output":
231+ // Aggregate(group by r_id, c)
232+ // RightOuterJoin
233+ // Project(l_id, r_id, coalesce(r_id, 1) AS c)
234+ // LeftOuterJoin
235+ // Scan L
236+ // Scan R(r_id NOT NULL UNIQUE)
237+ // Scan V
238+ // r_id is NOT NULL in R but becomes nullable at the inner LOJ output; the Project derives
239+ // r_id -> c from the expression. At the outer join output this FD must be dropped:
240+ // unmatched V rows inject (r_id=NULL, c=NULL), which collides with the Project's own
241+ // (r_id=NULL, c=1). After rewrite the sub-query alias is inlined into a plain project
242+ // (LogicalSubQueryAliasToLogicalProject) whose trait keeps the stale non-nullable r_id,
243+ // so the outer join must still be checked against the immediate child's current output.
244+ // c is kept in the select list so that it is not pruned away before the trait check.
245+ // Disable join reorder to keep the join tree stable (v LEFT OUTER JOIN p as written).
246+ connectContext .getSessionVariable ().setDisableJoinReorder (true );
247+ String sql = "select p.id, p.c, count(*) "
248+ + "from uni as v "
249+ + "left outer join ("
250+ + "select l.id2, r.id, coalesce(r.id, 1) as c "
251+ + "from agg as l left outer join uni as r on l.id2 = r.id2) p "
252+ + "on v.id2 = p.id2 "
253+ + "group by p.id, p.c" ;
254+
255+ LogicalAggregate <?> aggregate = (LogicalAggregate <?>) findNode (
256+ PlanChecker .from (connectContext ).analyze (sql ).getPlan (), n -> n instanceof LogicalAggregate );
257+ Assertions .assertNotNull (aggregate );
258+ // group by (r_id, c); both are plain slots after subquery inlining
259+ Slot rId = (Slot ) aggregate .getGroupByExpressions ().get (0 );
260+ Slot c = (Slot ) aggregate .getGroupByExpressions ().get (1 );
261+
262+ // logical path: the outer join's trait must not contain r_id -> c
263+ Plan rewritten = PlanChecker .from (connectContext ).analyze (sql ).rewrite ().getPlan ();
264+ LogicalJoin <?, ?> outerJoin = (LogicalJoin <?, ?>) findNode (rewritten , n -> n instanceof LogicalJoin );
265+ Assertions .assertNotNull (outerJoin , "rewritten plan: " + rewritten .treeString ());
266+ Assertions .assertFalse (outerJoin .getLogicalProperties ().getTrait ()
267+ .isDependent (ImmutableSet .of (rId ), ImmutableSet .of (c )),
268+ "r_id -> c must be dropped at the outer join since r_id is nullable on the outer side" );
269+
270+ // physical path: PhysicalHashJoin must drop r_id -> c as well; pick the outer join
271+ // (its subtree contains the inner join). implement() applies the implementation rules
272+ // directly (no CBO), so no table statistics are required.
273+ PhysicalPlan physicalPlan = PlanChecker .from (connectContext )
274+ .analyze (sql ).rewrite ().implement ().getPhysicalPlan ();
275+ PhysicalHashJoin <?, ?> physicalOuterJoin = (PhysicalHashJoin <?, ?>) findNode (physicalPlan ,
276+ n -> n instanceof PhysicalHashJoin
277+ && n .anyMatch (p -> p instanceof PhysicalHashJoin && p != n ));
278+ Assertions .assertNotNull (physicalOuterJoin , "physical plan: " + physicalPlan .treeString ());
279+ Assertions .assertFalse (physicalOuterJoin .getLogicalProperties ().getTrait ()
280+ .isDependent (ImmutableSet .of (rId ), ImmutableSet .of (c )),
281+ "physical join must also drop r_id -> c since r_id is nullable on the outer side" );
282+ }
283+
284+ private Plan findNode (Plan plan , Predicate <Plan > predicate ) {
285+ if (predicate .test (plan )) {
286+ return plan ;
287+ }
288+ for (Plan child : plan .children ()) {
289+ Plan found = findNode (child , predicate );
290+ if (found != null ) {
291+ return found ;
292+ }
293+ }
294+ return null ;
182295 }
183296
184297 @ Test
0 commit comments