4141//! Will skip loading partitions whose time ranges are entirely before 2020-02-01.
4242//! Supported operators: `=`, `<`, `>`, `<=`, `>=`, `BETWEEN`, `IN`, `AND`, `OR`.
4343
44- use std:: any:: Any ;
4544use std:: collections:: { HashMap , HashSet } ;
4645use std:: ffi:: CString ;
4746use std:: fmt:: Debug ;
4847use std:: sync:: Arc ;
4948
5049use arrow:: array:: RecordBatch ;
5150use arrow:: datatypes:: { Schema , SchemaRef } ;
52- use arrow:: pyarrow:: FromPyArrow ;
51+ use arrow:: pyarrow:: { FromPyArrow , ToPyArrow } ;
5352use async_stream:: try_stream;
5453use async_trait:: async_trait;
5554use datafusion:: catalog:: streaming:: StreamingTable ;
5655use datafusion:: catalog:: Session ;
57- use datafusion:: common:: { DataFusionError , Result as DFResult , ScalarValue } ;
56+ use datafusion:: common:: stats:: Precision ;
57+ use datafusion:: common:: { DataFusionError , Result as DFResult , ScalarValue , Statistics } ;
5858use datafusion:: datasource:: TableProvider ;
5959use datafusion:: execution:: TaskContext ;
6060use datafusion:: logical_expr:: expr:: InList ;
@@ -63,7 +63,9 @@ use datafusion::logical_expr::{
6363} ;
6464use datafusion:: physical_plan:: stream:: RecordBatchStreamAdapter ;
6565use datafusion:: physical_plan:: streaming:: PartitionStream ;
66- use datafusion:: physical_plan:: { ExecutionPlan , SendableRecordBatchStream } ;
66+ use datafusion:: physical_plan:: {
67+ DisplayAs , DisplayFormatType , ExecutionPlan , PlanProperties , SendableRecordBatchStream ,
68+ } ;
6769use datafusion_ffi:: proto:: logical_extension_codec:: FFI_LogicalExtensionCodec ;
6870use datafusion_ffi:: table_provider:: FFI_TableProvider ;
6971use pyo3:: prelude:: * ;
@@ -136,6 +138,14 @@ pub struct DimensionRange {
136138pub struct PartitionMetadata {
137139 /// Dimension ranges for this partition, keyed by column name
138140 pub ranges : HashMap < String , DimensionRange > ,
141+ /// Exact number of rows in this partition (product of the chunk's
142+ /// per-dimension sizes). `None` when the producer did not supply it.
143+ /// Used to report exact `Statistics::num_rows` to the optimizer so
144+ /// cost-based rules (join build-side selection, broadcast vs. shuffle)
145+ /// have real cardinalities instead of guesses. xarray knows this
146+ /// exactly — it is the product of the partition's dimension lengths —
147+ /// so unlike most table providers these statistics are not estimates.
148+ pub num_rows : Option < usize > ,
139149}
140150
141151impl PartitionMetadata {
@@ -507,7 +517,10 @@ fn convert_python_metadata_from_bound(meta_obj: &Bound<'_, PyAny>) -> PyResult<P
507517 } ,
508518 ) ;
509519 }
510- Ok ( PartitionMetadata { ranges } )
520+ Ok ( PartitionMetadata {
521+ ranges,
522+ num_rows : None ,
523+ } )
511524}
512525
513526impl Debug for PrunableStreamingTable {
@@ -522,10 +535,6 @@ impl Debug for PrunableStreamingTable {
522535
523536#[ async_trait]
524537impl TableProvider for PrunableStreamingTable {
525- fn as_any ( & self ) -> & dyn Any {
526- self
527- }
528-
529538 fn schema ( & self ) -> SchemaRef {
530539 Arc :: clone ( & self . schema )
531540 }
@@ -563,10 +572,27 @@ impl TableProvider for PrunableStreamingTable {
563572 // Prune partitions based on filters
564573 let included_indices = self . prune_partitions ( filters) ;
565574
575+ // Exact per-partition row counts for the partitions that survive
576+ // pruning, in scan order. These feed `XarrayScanExec`'s statistics so
577+ // the optimizer sees real cardinalities.
578+ let included_metas: Vec < & PartitionMetadata > = included_indices
579+ . iter ( )
580+ . map ( |& idx| & self . partitions [ idx] . 1 )
581+ . collect ( ) ;
582+ let partition_rows: Vec < Precision < usize > > = included_metas
583+ . iter ( )
584+ . map ( |meta| match meta. num_rows {
585+ Some ( n) => Precision :: Exact ( n) ,
586+ None => Precision :: Absent ,
587+ } )
588+ . collect ( ) ;
589+
566590 // Handle empty case — all partitions pruned, return empty plan
567591 if included_indices. is_empty ( ) {
568592 let empty_table = StreamingTable :: try_new ( Arc :: clone ( & self . schema ) , vec ! [ ] ) ?;
569- return empty_table. scan ( state, projection, filters, limit) . await ;
593+ let inner = empty_table. scan ( state, projection, filters, limit) . await ?;
594+ let stats = build_scan_statistics ( inner. schema ( ) . as_ref ( ) , & included_metas) ;
595+ return Ok ( Arc :: new ( XarrayScanExec :: new ( inner, stats, partition_rows) ) ) ;
570596 }
571597
572598 // Determine whether to push projection down to the Python factory.
@@ -622,7 +648,9 @@ impl TableProvider for PrunableStreamingTable {
622648 // StreamingTable already has the projected schema — pass None for
623649 // projection so it doesn't wrap the stream in a redundant ProjectionExec.
624650 let streaming = StreamingTable :: try_new ( projected_schema, projected_partitions) ?;
625- streaming. scan ( state, None , filters, limit) . await
651+ let inner = streaming. scan ( state, None , filters, limit) . await ?;
652+ let stats = build_scan_statistics ( inner. schema ( ) . as_ref ( ) , & included_metas) ;
653+ Ok ( Arc :: new ( XarrayScanExec :: new ( inner, stats, partition_rows) ) )
626654 } else {
627655 // No projection pushdown — factory is called with None (loads all
628656 // columns). StreamingTable applies projection via ProjectionExec.
@@ -631,7 +659,138 @@ impl TableProvider for PrunableStreamingTable {
631659 . map ( |& idx| self . partitions [ idx] . 0 . clone_as_stream ( ) )
632660 . collect ( ) ;
633661 let streaming = StreamingTable :: try_new ( Arc :: clone ( & self . schema ) , included_partitions) ?;
634- streaming. scan ( state, projection, filters, limit) . await
662+ let inner = streaming. scan ( state, projection, filters, limit) . await ?;
663+ let stats = build_scan_statistics ( inner. schema ( ) . as_ref ( ) , & included_metas) ;
664+ Ok ( Arc :: new ( XarrayScanExec :: new ( inner, stats, partition_rows) ) )
665+ }
666+ }
667+ }
668+
669+ // ============================================================================
670+ // Exact Statistics + Scan Wrapper
671+ // ============================================================================
672+
673+ /// Sum a set of optional per-partition row counts into a `Precision<usize>`.
674+ ///
675+ /// Exact only when *every* partition reports a count; if any is missing we
676+ /// return `Absent` rather than an under-count, so the optimizer never sees a
677+ /// cardinality smaller than reality.
678+ fn sum_row_counts < ' a > ( metas : impl Iterator < Item = & ' a PartitionMetadata > ) -> Precision < usize > {
679+ let mut total: usize = 0 ;
680+ for meta in metas {
681+ match meta. num_rows {
682+ Some ( n) => total += n,
683+ None => return Precision :: Absent ,
684+ }
685+ }
686+ Precision :: Exact ( total)
687+ }
688+
689+ /// Build `Statistics` for a scan over the given partitions.
690+ ///
691+ /// Only `num_rows` is reported — the summed product of each surviving chunk's
692+ /// dimension sizes, which is exact rather than an estimate. That is what the
693+ /// cost-based `JoinSelection` rule needs to choose a hash-join build side and
694+ /// what lets `COUNT(*)` be answered without a scan. Column min/max would add
695+ /// range hints but do not change those decisions (WHERE filters are already
696+ /// handled by partition pruning), so they are omitted to keep this simple.
697+ fn build_scan_statistics ( output_schema : & Schema , metas : & [ & PartitionMetadata ] ) -> Statistics {
698+ let mut stats = Statistics :: new_unknown ( output_schema) ;
699+ stats. num_rows = sum_row_counts ( metas. iter ( ) . copied ( ) ) ;
700+ stats
701+ }
702+
703+ /// A thin scan operator that wraps an inner `StreamingTableExec` and reports
704+ /// exact `Statistics` to the query optimizer.
705+ ///
706+ /// Execution, schema, ordering, and partitioning are delegated verbatim to the
707+ /// inner plan (so projection mechanics are reused unchanged); the only thing
708+ /// this node adds is real cardinality. `StreamingTableExec` reports unknown
709+ /// statistics, and the physical `JoinSelection` rule reads statistics from the
710+ /// `ExecutionPlan` (not from `TableProvider::statistics`) — even in DataFusion
711+ /// 54, which forwards `ExecutionPlan` statistics across the FFI boundary — so
712+ /// this wrapper is what carries the exact cardinality through to the optimizer.
713+ #[ derive( Debug ) ]
714+ struct XarrayScanExec {
715+ inner : Arc < dyn ExecutionPlan > ,
716+ statistics : Statistics ,
717+ /// Exact row count per output partition (parallel to `inner` partitions),
718+ /// so `partition_statistics(Some(i))` is exact too.
719+ partition_rows : Vec < Precision < usize > > ,
720+ }
721+
722+ impl XarrayScanExec {
723+ fn new (
724+ inner : Arc < dyn ExecutionPlan > ,
725+ statistics : Statistics ,
726+ partition_rows : Vec < Precision < usize > > ,
727+ ) -> Self {
728+ Self {
729+ inner,
730+ statistics,
731+ partition_rows,
732+ }
733+ }
734+ }
735+
736+ impl DisplayAs for XarrayScanExec {
737+ fn fmt_as ( & self , t : DisplayFormatType , f : & mut std:: fmt:: Formatter ) -> std:: fmt:: Result {
738+ match t {
739+ DisplayFormatType :: Default | DisplayFormatType :: Verbose => {
740+ write ! ( f, "XarrayScanExec: rows={:?}" , self . statistics. num_rows)
741+ }
742+ DisplayFormatType :: TreeRender => {
743+ write ! ( f, "rows={:?}" , self . statistics. num_rows)
744+ }
745+ }
746+ }
747+ }
748+
749+ #[ async_trait]
750+ impl ExecutionPlan for XarrayScanExec {
751+ fn name ( & self ) -> & str {
752+ "XarrayScanExec"
753+ }
754+
755+ fn properties ( & self ) -> & Arc < PlanProperties > {
756+ // Delegate partitioning + output ordering + boundedness to the inner
757+ // StreamingTableExec.
758+ self . inner . properties ( )
759+ }
760+
761+ fn children ( & self ) -> Vec < & Arc < dyn ExecutionPlan > > {
762+ // A scan is a leaf; the inner plan is an execution detail, not a child
763+ // the optimizer should rewrite.
764+ vec ! [ ]
765+ }
766+
767+ fn with_new_children (
768+ self : Arc < Self > ,
769+ _children : Vec < Arc < dyn ExecutionPlan > > ,
770+ ) -> DFResult < Arc < dyn ExecutionPlan > > {
771+ Ok ( self )
772+ }
773+
774+ fn execute (
775+ & self ,
776+ partition : usize ,
777+ ctx : Arc < TaskContext > ,
778+ ) -> DFResult < SendableRecordBatchStream > {
779+ self . inner . execute ( partition, ctx)
780+ }
781+
782+ fn partition_statistics ( & self , partition : Option < usize > ) -> DFResult < Arc < Statistics > > {
783+ match partition {
784+ None => Ok ( Arc :: new ( self . statistics . clone ( ) ) ) ,
785+ Some ( i) => {
786+ let mut s = self . statistics . clone ( ) ;
787+ s. num_rows = self
788+ . partition_rows
789+ . get ( i)
790+ . cloned ( )
791+ . unwrap_or ( Precision :: Absent ) ;
792+ Ok ( Arc :: new ( s) )
793+ }
635794 }
636795 }
637796}
@@ -810,27 +969,26 @@ fn ffi_logical_codec_from_pycapsule(
810969 session
811970 } ;
812971
813- let capsule = capsule. downcast :: < PyCapsule > ( ) . map_err ( |e| {
972+ let capsule = capsule. cast :: < PyCapsule > ( ) . map_err ( |e| {
814973 pyo3:: exceptions:: PyValueError :: new_err ( format ! (
815974 "session did not produce a PyCapsule for the logical extension codec: {e}"
816975 ) )
817976 } ) ?;
818977
819- let capsule_name = capsule. name ( ) ?. ok_or_else ( || {
820- pyo3:: exceptions:: PyValueError :: new_err (
821- "datafusion_logical_extension_codec PyCapsule has no name set" ,
822- )
823- } ) ?;
824- let capsule_name = capsule_name. to_str ( ) ?;
825- if capsule_name != "datafusion_logical_extension_codec" {
826- return Err ( pyo3:: exceptions:: PyValueError :: new_err ( format ! (
827- "expected capsule name 'datafusion_logical_extension_codec', got '{capsule_name}'"
828- ) ) ) ;
829- }
978+ // `pointer_checked` validates the capsule name matches before handing back
979+ // the pointer, so an unexpectedly-named capsule is rejected here.
980+ let expected = CString :: new ( "datafusion_logical_extension_codec" ) . unwrap ( ) ;
981+ let ptr = capsule
982+ . pointer_checked ( Some ( expected. as_c_str ( ) ) )
983+ . map_err ( |e| {
984+ pyo3:: exceptions:: PyValueError :: new_err ( format ! (
985+ "capsule is not a datafusion_logical_extension_codec: {e}"
986+ ) )
987+ } ) ?;
830988
831989 // SAFETY: The capsule was produced by datafusion-python and contains a
832990 // valid FFI_LogicalExtensionCodec (#[repr(C)] StableAbi struct).
833- let codec = unsafe { capsule . reference :: < FFI_LogicalExtensionCodec > ( ) } ;
991+ let codec = unsafe { & * ( ptr . as_ptr ( ) as * const FFI_LogicalExtensionCodec ) } ;
834992 Ok ( codec. clone ( ) )
835993}
836994
@@ -921,12 +1079,24 @@ impl LazyArrowStreamTable {
9211079 let mut partition_list: Vec < ( Arc < dyn ProjectableStream > , PartitionMetadata ) > = Vec :: new ( ) ;
9221080 for item_result in partitions. try_iter ( ) ? {
9231081 let item = item_result?;
924- let ( factory_obj, meta_obj) : ( Py < PyAny > , Py < PyAny > ) = item. extract ( ) . map_err ( |e| {
925- pyo3:: exceptions:: PyTypeError :: new_err ( format ! (
926- "each partition must be a (factory, metadata_dict) tuple: {e}"
927- ) )
928- } ) ?;
929- let meta = convert_python_metadata_from_bound ( meta_obj. bind ( partitions. py ( ) ) ) ?;
1082+ // Accept either ``(factory, metadata)`` (legacy) or
1083+ // ``(factory, metadata, num_rows)`` (preferred — carries the exact
1084+ // partition row count for statistics). Try the 3-tuple first.
1085+ let ( factory_obj, meta_obj, num_rows) : ( Py < PyAny > , Py < PyAny > , Option < usize > ) =
1086+ match item. extract :: < ( Py < PyAny > , Py < PyAny > , usize ) > ( ) {
1087+ Ok ( ( f, m, n) ) => ( f, m, Some ( n) ) ,
1088+ Err ( _) => {
1089+ let ( f, m) : ( Py < PyAny > , Py < PyAny > ) = item. extract ( ) . map_err ( |e| {
1090+ pyo3:: exceptions:: PyTypeError :: new_err ( format ! (
1091+ "each partition must be a (factory, metadata_dict) or \
1092+ (factory, metadata_dict, num_rows) tuple: {e}"
1093+ ) )
1094+ } ) ?;
1095+ ( f, m, None )
1096+ }
1097+ } ;
1098+ let mut meta = convert_python_metadata_from_bound ( meta_obj. bind ( partitions. py ( ) ) ) ?;
1099+ meta. num_rows = num_rows;
9301100 let partition: Arc < dyn ProjectableStream > =
9311101 Arc :: new ( PyArrowStreamPartition :: new ( factory_obj, schema_ref. clone ( ) ) ) ;
9321102 partition_list. push ( ( partition, meta) ) ;
@@ -969,7 +1139,6 @@ impl LazyArrowStreamTable {
9691139
9701140 /// Get the schema of the table as a PyArrow Schema.
9711141 fn schema ( & self , py : Python < ' _ > ) -> PyResult < Py < PyAny > > {
972- use arrow:: pyarrow:: ToPyArrow ;
9731142 self . table
9741143 . schema ( )
9751144 . to_pyarrow ( py)
0 commit comments