@@ -289,6 +289,115 @@ impl ExecutionPlan for TestFetchOnlyExec {
289289 }
290290}
291291
292+ /// Test multi-child plan with a single output partition that allows limit
293+ /// pushdown. Optionally absorbs a fetch via `with_fetch`.
294+ #[ derive( Debug , Clone ) ]
295+ struct TestMultiChildExec {
296+ inputs : Vec < Arc < dyn ExecutionPlan > > ,
297+ properties : Arc < PlanProperties > ,
298+ supports_fetch : bool ,
299+ fetch : Option < usize > ,
300+ }
301+
302+ impl TestMultiChildExec {
303+ fn new ( inputs : Vec < Arc < dyn ExecutionPlan > > ) -> Self {
304+ let properties = PlanProperties :: new (
305+ EquivalenceProperties :: new ( inputs[ 0 ] . schema ( ) ) ,
306+ Partitioning :: UnknownPartitioning ( 1 ) ,
307+ EmissionType :: Incremental ,
308+ Boundedness :: Bounded ,
309+ ) ;
310+ Self {
311+ inputs,
312+ properties : Arc :: new ( properties) ,
313+ supports_fetch : false ,
314+ fetch : None ,
315+ }
316+ }
317+
318+ /// Set whether `with_fetch()` returns `Some` (true) or `None` (false).
319+ fn with_supports_fetch ( mut self , supports : bool ) -> Self {
320+ self . supports_fetch = supports;
321+ self
322+ }
323+ }
324+
325+ impl DisplayAs for TestMultiChildExec {
326+ fn fmt_as ( & self , _t : DisplayFormatType , f : & mut Formatter ) -> std:: fmt:: Result {
327+ write ! ( f, "TestMultiChildExec" ) ?;
328+ if let Some ( fetch) = self . fetch {
329+ write ! ( f, ": fetch={fetch}" ) ?;
330+ }
331+ Ok ( ( ) )
332+ }
333+ }
334+
335+ impl ExecutionPlan for TestMultiChildExec {
336+ fn name ( & self ) -> & str {
337+ "TestMultiChildExec"
338+ }
339+
340+ fn properties ( & self ) -> & Arc < PlanProperties > {
341+ & self . properties
342+ }
343+
344+ fn children ( & self ) -> Vec < & Arc < dyn ExecutionPlan > > {
345+ self . inputs . iter ( ) . collect ( )
346+ }
347+
348+ fn apply_expressions (
349+ & self ,
350+ _f : & mut dyn FnMut ( & PhysicalExprRef ) -> Result < TreeNodeRecursion > ,
351+ ) -> Result < TreeNodeRecursion > {
352+ // `TestMultiChildExec` owns no `PhysicalExpr`s.
353+ Ok ( TreeNodeRecursion :: Continue )
354+ }
355+
356+ fn with_new_children (
357+ self : Arc < Self > ,
358+ children : Vec < Arc < dyn ExecutionPlan > > ,
359+ ) -> Result < Arc < dyn ExecutionPlan > > {
360+ assert_eq ! ( children. len( ) , self . inputs. len( ) ) ;
361+ let mut new_plan = Self :: new ( children) . with_supports_fetch ( self . supports_fetch ) ;
362+ new_plan. fetch = self . fetch ;
363+ Ok ( Arc :: new ( new_plan) )
364+ }
365+
366+ fn execute (
367+ & self ,
368+ _partition : usize ,
369+ _context : Arc < TaskContext > ,
370+ ) -> Result < SendableRecordBatchStream > {
371+ unreachable ! ( "TestMultiChildExec is only used by optimizer tests" )
372+ }
373+
374+ fn statistics_from_inputs (
375+ & self ,
376+ _input_stats : & [ Arc < Statistics > ] ,
377+ _args : & StatisticsArgs ,
378+ ) -> Result < Arc < Statistics > > {
379+ Ok ( Arc :: new ( Statistics :: new_unknown ( self . schema ( ) . as_ref ( ) ) ) )
380+ }
381+
382+ fn supports_limit_pushdown ( & self ) -> bool {
383+ true
384+ }
385+
386+ fn with_fetch ( & self , fetch : Option < usize > ) -> Option < Arc < dyn ExecutionPlan > > {
387+ if self . supports_fetch {
388+ let mut new_plan = self . clone ( ) ;
389+ new_plan. fetch = fetch;
390+ Some ( Arc :: new ( new_plan) )
391+ } else {
392+ None
393+ }
394+ }
395+
396+ fn fetch ( & self ) -> Option < usize > {
397+ self . fetch
398+ }
399+ }
400+
292401#[ test]
293402fn transforms_streaming_table_exec_into_fetching_version_when_skip_is_zero ( ) -> Result < ( ) >
294403{
@@ -573,6 +682,111 @@ fn materializes_pending_global_limit_below_extension_combiner() -> Result<()> {
573682 Ok ( ( ) )
574683}
575684
685+ #[ test]
686+ fn materializes_global_limit_before_multi_child_extension ( ) -> Result < ( ) > {
687+ // Regression test: a pending global limit used to be cloned to every
688+ // child of a multi-child node, so each child applied the full LIMIT and
689+ // the merged output exceeded it. The limit must stay above the node.
690+ let schema = create_schema ( ) ;
691+ let left =
692+ Arc :: new ( TestScan :: new ( Arc :: clone ( & schema) , vec ! [ ] ) . with_supports_fetch ( true ) ) ;
693+ let right = Arc :: new ( TestScan :: new ( schema, vec ! [ ] ) . with_supports_fetch ( true ) ) ;
694+ let custom = Arc :: new ( TestMultiChildExec :: new ( vec ! [ left, right] ) ) ;
695+ let global_limit = global_limit_exec ( custom, 0 , Some ( 5 ) ) ;
696+
697+ let optimized = LimitPushdown :: new ( ) . optimize ( global_limit, & ConfigOptions :: new ( ) ) ?;
698+
699+ insta:: assert_snapshot!(
700+ format_plan( & optimized) ,
701+ @r"
702+ GlobalLimitExec: skip=0, fetch=5
703+ TestMultiChildExec
704+ TestScan: fetch=5
705+ TestScan: fetch=5
706+ "
707+ ) ;
708+
709+ Ok ( ( ) )
710+ }
711+
712+ #[ test]
713+ fn materializes_global_offset_limit_before_multi_child_extension ( ) -> Result < ( ) > {
714+ // The offset stays in the GlobalLimitExec; children only get a fetch hint
715+ // of skip + fetch for early stopping.
716+ let schema = create_schema ( ) ;
717+ let left =
718+ Arc :: new ( TestScan :: new ( Arc :: clone ( & schema) , vec ! [ ] ) . with_supports_fetch ( true ) ) ;
719+ let right = Arc :: new ( TestScan :: new ( schema, vec ! [ ] ) . with_supports_fetch ( true ) ) ;
720+ let custom = Arc :: new ( TestMultiChildExec :: new ( vec ! [ left, right] ) ) ;
721+ let global_limit = global_limit_exec ( custom, 2 , Some ( 5 ) ) ;
722+
723+ let optimized = LimitPushdown :: new ( ) . optimize ( global_limit, & ConfigOptions :: new ( ) ) ?;
724+
725+ insta:: assert_snapshot!(
726+ format_plan( & optimized) ,
727+ @r"
728+ GlobalLimitExec: skip=2, fetch=5
729+ TestMultiChildExec
730+ TestScan: fetch=7
731+ TestScan: fetch=7
732+ "
733+ ) ;
734+
735+ Ok ( ( ) )
736+ }
737+
738+ #[ test]
739+ fn multi_child_extension_absorbs_global_limit_and_hints_children ( ) -> Result < ( ) > {
740+ // When the multi-child node absorbs the fetch itself, no extra limit is
741+ // needed; children still receive the same fetch for early stopping.
742+ let schema = create_schema ( ) ;
743+ let left =
744+ Arc :: new ( TestScan :: new ( Arc :: clone ( & schema) , vec ! [ ] ) . with_supports_fetch ( true ) ) ;
745+ let right = Arc :: new ( TestScan :: new ( schema, vec ! [ ] ) . with_supports_fetch ( true ) ) ;
746+ let custom =
747+ Arc :: new ( TestMultiChildExec :: new ( vec ! [ left, right] ) . with_supports_fetch ( true ) ) ;
748+ let global_limit = global_limit_exec ( custom, 0 , Some ( 5 ) ) ;
749+
750+ let optimized = LimitPushdown :: new ( ) . optimize ( global_limit, & ConfigOptions :: new ( ) ) ?;
751+
752+ insta:: assert_snapshot!(
753+ format_plan( & optimized) ,
754+ @r"
755+ TestMultiChildExec: fetch=5
756+ TestScan: fetch=5
757+ TestScan: fetch=5
758+ "
759+ ) ;
760+
761+ Ok ( ( ) )
762+ }
763+
764+ #[ test]
765+ fn materializes_local_limit_before_multi_child_extension ( ) -> Result < ( ) > {
766+ // A local limit also cannot be replicated to every child of a multi-child
767+ // node with a single output partition.
768+ let schema = create_schema ( ) ;
769+ let left =
770+ Arc :: new ( TestScan :: new ( Arc :: clone ( & schema) , vec ! [ ] ) . with_supports_fetch ( true ) ) ;
771+ let right = Arc :: new ( TestScan :: new ( schema, vec ! [ ] ) . with_supports_fetch ( true ) ) ;
772+ let custom = Arc :: new ( TestMultiChildExec :: new ( vec ! [ left, right] ) ) ;
773+ let local_limit = local_limit_exec ( custom, 5 ) ;
774+
775+ let optimized = LimitPushdown :: new ( ) . optimize ( local_limit, & ConfigOptions :: new ( ) ) ?;
776+
777+ insta:: assert_snapshot!(
778+ format_plan( & optimized) ,
779+ @r"
780+ GlobalLimitExec: skip=0, fetch=5
781+ TestMultiChildExec
782+ TestScan: fetch=5
783+ TestScan: fetch=5
784+ "
785+ ) ;
786+
787+ Ok ( ( ) )
788+ }
789+
576790#[ test]
577791fn upgrades_pending_local_limit_before_extension_combiner ( ) -> Result < ( ) > {
578792 let schema = create_schema ( ) ;
0 commit comments