@@ -714,3 +714,123 @@ fn no_limit_preserves_plan_identity() -> Result<()> {
714714
715715 Ok ( ( ) )
716716}
717+
718+ #[ test]
719+ fn outer_offset_does_not_leak_through_sort_into_inner_limit ( ) -> Result < ( ) > {
720+ // Regression test for https://github.com/apache/datafusion/issues/22489
721+ //
722+ // When an outer OFFSET is separated from an inner LIMIT by a SortExec
723+ // with different sort keys, the outer skip must not reduce the inner
724+ // fetch. Before the fix, combine_limit merged them, producing
725+ // GlobalLimitExec(skip=1, fetch=7) instead of preserving the inner
726+ // LIMIT 8.
727+ //
728+ // Plan structure:
729+ // GlobalLimitExec: skip=1, fetch=None (outer OFFSET 1)
730+ // SortExec: [c1 DESC] (outer sort — different key)
731+ // GlobalLimitExec: skip=0, fetch=8 (inner LIMIT 8)
732+ // SortExec: [c2 ASC] (inner sort — different key)
733+ // EmptyExec
734+ let schema = create_schema ( ) ;
735+ let empty = empty_exec ( Arc :: clone ( & schema) ) ;
736+
737+ let inner_ordering: LexOrdering = [ PhysicalSortExpr {
738+ expr : col ( "c2" , & schema) ?,
739+ options : SortOptions :: default ( ) ,
740+ } ]
741+ . into ( ) ;
742+ let inner_sort = sort_exec ( inner_ordering, empty) ;
743+ let inner_limit = global_limit_exec ( inner_sort, 0 , Some ( 8 ) ) ;
744+
745+ let outer_ordering: LexOrdering = [ PhysicalSortExpr {
746+ expr : col ( "c1" , & schema) ?,
747+ options : SortOptions {
748+ descending : true ,
749+ nulls_first : false ,
750+ } ,
751+ } ]
752+ . into ( ) ;
753+ let outer_sort = sort_exec ( outer_ordering, inner_limit) ;
754+ let outer_limit = global_limit_exec ( outer_sort, 1 , None ) ;
755+
756+ let initial = format_plan ( & outer_limit) ;
757+ insta:: assert_snapshot!(
758+ initial,
759+ @r"
760+ GlobalLimitExec: skip=1, fetch=None
761+ SortExec: expr=[c1@0 DESC NULLS LAST], preserve_partitioning=[false]
762+ GlobalLimitExec: skip=0, fetch=8
763+ SortExec: expr=[c2@1 ASC], preserve_partitioning=[false]
764+ EmptyExec
765+ "
766+ ) ;
767+
768+ let after_optimize =
769+ LimitPushdown :: new ( ) . optimize ( outer_limit, & ConfigOptions :: new ( ) ) ?;
770+ let optimized = format_plan ( & after_optimize) ;
771+ insta:: assert_snapshot!(
772+ optimized,
773+ @r"
774+ GlobalLimitExec: skip=1, fetch=None
775+ SortExec: expr=[c1@0 DESC NULLS LAST], preserve_partitioning=[false]
776+ SortExec: TopK(fetch=8), expr=[c2@1 ASC], preserve_partitioning=[false]
777+ EmptyExec
778+ "
779+ ) ;
780+
781+ Ok ( ( ) )
782+ }
783+
784+ #[ test]
785+ fn outer_offset_with_same_sort_key_still_pushes_limit ( ) -> Result < ( ) > {
786+ // Companion to outer_offset_does_not_leak_through_sort_into_inner_limit:
787+ // when both sorts use the *same* key, the inner LIMIT should still be
788+ // pushed into the SortExec as TopK.
789+ //
790+ // Plan structure:
791+ // GlobalLimitExec: skip=1, fetch=None (outer OFFSET 1)
792+ // SortExec: [c1 ASC] (outer sort — same key)
793+ // GlobalLimitExec: skip=0, fetch=8 (inner LIMIT 8)
794+ // SortExec: [c1 ASC] (inner sort — same key)
795+ // EmptyExec
796+ let schema = create_schema ( ) ;
797+ let empty = empty_exec ( Arc :: clone ( & schema) ) ;
798+
799+ let ordering: LexOrdering = [ PhysicalSortExpr {
800+ expr : col ( "c1" , & schema) ?,
801+ options : SortOptions :: default ( ) ,
802+ } ]
803+ . into ( ) ;
804+
805+ let inner_sort = sort_exec ( ordering. clone ( ) , empty) ;
806+ let inner_limit = global_limit_exec ( inner_sort, 0 , Some ( 8 ) ) ;
807+ let outer_sort = sort_exec ( ordering, inner_limit) ;
808+ let outer_limit = global_limit_exec ( outer_sort, 1 , None ) ;
809+
810+ let initial = format_plan ( & outer_limit) ;
811+ insta:: assert_snapshot!(
812+ initial,
813+ @r"
814+ GlobalLimitExec: skip=1, fetch=None
815+ SortExec: expr=[c1@0 ASC], preserve_partitioning=[false]
816+ GlobalLimitExec: skip=0, fetch=8
817+ SortExec: expr=[c1@0 ASC], preserve_partitioning=[false]
818+ EmptyExec
819+ "
820+ ) ;
821+
822+ let after_optimize =
823+ LimitPushdown :: new ( ) . optimize ( outer_limit, & ConfigOptions :: new ( ) ) ?;
824+ let optimized = format_plan ( & after_optimize) ;
825+ insta:: assert_snapshot!(
826+ optimized,
827+ @r"
828+ GlobalLimitExec: skip=1, fetch=None
829+ SortExec: expr=[c1@0 ASC], preserve_partitioning=[false]
830+ SortExec: TopK(fetch=8), expr=[c1@0 ASC], preserve_partitioning=[false]
831+ EmptyExec
832+ "
833+ ) ;
834+
835+ Ok ( ( ) )
836+ }
0 commit comments