@@ -50,7 +50,7 @@ use std::fmt::Debug;
5050use std:: sync:: Arc ;
5151
5252use arrow:: array:: RecordBatch ;
53- use arrow:: datatypes:: SchemaRef ;
53+ use arrow:: datatypes:: { Schema , SchemaRef } ;
5454use arrow:: pyarrow:: FromPyArrow ;
5555use async_stream:: try_stream;
5656use async_trait:: async_trait;
@@ -68,7 +68,7 @@ use datafusion::physical_plan::streaming::PartitionStream;
6868use datafusion:: physical_plan:: { ExecutionPlan , SendableRecordBatchStream } ;
6969use datafusion_ffi:: table_provider:: FFI_TableProvider ;
7070use pyo3:: prelude:: * ;
71- use pyo3:: types:: PyCapsule ;
71+ use pyo3:: types:: { PyCapsule , PyList } ;
7272use tokio:: runtime:: Handle ;
7373
7474// ============================================================================
@@ -157,16 +157,18 @@ impl PartitionMetadata {
157157/// `TableProvider::supports_filters_pushdown` and partition pruning in `scan()`.
158158struct PrunableStreamingTable {
159159 schema : SchemaRef ,
160- /// Partition streams paired with their coordinate range metadata
161- partitions : Vec < ( Arc < dyn PartitionStream > , PartitionMetadata ) > ,
160+ /// Partition streams paired with their coordinate range metadata.
161+ /// Stored behind the `ProjectableStream` trait so `PrunableStreamingTable`
162+ /// is not coupled to `PyArrowStreamPartition`.
163+ partitions : Vec < ( Arc < dyn ProjectableStream > , PartitionMetadata ) > ,
162164 /// Set of column names that are dimension columns (eligible for pruning)
163165 dimension_columns : HashSet < String > ,
164166}
165167
166168impl PrunableStreamingTable {
167169 fn new (
168170 schema : SchemaRef ,
169- partitions : Vec < ( Arc < dyn PartitionStream > , PartitionMetadata ) > ,
171+ partitions : Vec < ( Arc < dyn ProjectableStream > , PartitionMetadata ) > ,
170172 ) -> Self {
171173 // Collect dimension column names from the first partition that has
172174 // non-empty metadata. All partitions share the same dimension names,
@@ -435,6 +437,24 @@ impl PrunableStreamingTable {
435437 }
436438}
437439
440+ /// Extension trait for partition streams that support column projection.
441+ ///
442+ /// Implemented by `PyArrowStreamPartition` so that `PrunableStreamingTable`
443+ /// can push projections to Python factories without coupling to the concrete type.
444+ /// Any new stream implementation (e.g. for non-Python backends) can implement this
445+ /// trait and be used with `PrunableStreamingTable` directly.
446+ trait ProjectableStream : PartitionStream + Debug {
447+ /// Return a new stream that emits only the specified columns.
448+ fn clone_with_projection (
449+ & self ,
450+ projection : Arc < [ String ] > ,
451+ projected_schema : SchemaRef ,
452+ ) -> Arc < dyn PartitionStream > ;
453+
454+ /// Clone this stream as a generic `PartitionStream` Arc.
455+ fn clone_as_stream ( & self ) -> Arc < dyn PartitionStream > ;
456+ }
457+
438458/// Flip a comparison operator (for when literal is on left side).
439459fn flip_operator ( op : & Operator ) -> Operator {
440460 match op {
@@ -545,49 +565,137 @@ impl TableProvider for PrunableStreamingTable {
545565 // Prune partitions based on filters
546566 let included_indices = self . prune_partitions ( filters) ;
547567
548- // Collect only the included partition streams
549- let included_partitions: Vec < Arc < dyn PartitionStream > > = included_indices
550- . iter ( )
551- . map ( |& idx| Arc :: clone ( & self . partitions [ idx] . 0 ) )
552- . collect ( ) ;
553-
554- // Handle empty case - create an empty streaming table
555- if included_partitions. is_empty ( ) {
556- // Create a streaming table with no partitions
557- // DataFusion will return empty result
568+ // Handle empty case — all partitions pruned, return empty plan
569+ if included_indices. is_empty ( ) {
558570 let empty_table = StreamingTable :: try_new ( Arc :: clone ( & self . schema ) , vec ! [ ] ) ?;
559571 return empty_table. scan ( state, projection, filters, limit) . await ;
560572 }
561573
562- // Create StreamingTable with the pruned partitions
563- let streaming = StreamingTable :: try_new ( Arc :: clone ( & self . schema ) , included_partitions) ?;
574+ // Determine whether to push projection down to the Python factory.
575+ //
576+ // We push when the projection includes at least one data variable
577+ // (non-dimension column), because xarray can selectively load only
578+ // the requested data arrays while dimension coordinates are always
579+ // available via xarray's coordinate system.
580+ //
581+ // We do NOT push when:
582+ // - projection is None (load everything — factory receives None)
583+ // - projection is Some([]) (COUNT(*) — let StreamingTable handle)
584+ // - projection contains only dimension columns (ds.to_dataframe()
585+ // needs at least one data variable; dimensions are always loaded)
586+ let push_projection = match projection {
587+ Some ( indices) if !indices. is_empty ( ) => indices
588+ . iter ( )
589+ . any ( |& i| !self . dimension_columns . contains ( self . schema . field ( i) . name ( ) ) ) ,
590+ _ => false ,
591+ } ;
564592
565- // Delegate to StreamingTable for actual execution.
566- // We report Inexact, so DataFusion still applies row-level filtering.
567- streaming. scan ( state, projection, filters, limit) . await
593+ if push_projection {
594+ let indices = projection. unwrap ( ) ;
595+
596+ // Build the projected schema (only the requested fields)
597+ let proj_fields: Vec < _ > = indices
598+ . iter ( )
599+ . map ( |& i| self . schema . field ( i) . clone ( ) )
600+ . collect ( ) ;
601+ let projected_schema = Arc :: new ( Schema :: new ( proj_fields) ) ;
602+
603+ // Collect the requested column names to send to the factory.
604+ // Stored in an Arc so each clone_with_projection call shares the
605+ // same allocation via an atomic refcount increment (no N Vec copies).
606+ let proj_col_names: Arc < [ String ] > = indices
607+ . iter ( )
608+ . map ( |& i| self . schema . field ( i) . name ( ) . to_string ( ) )
609+ . collect :: < Vec < _ > > ( )
610+ . into ( ) ;
611+
612+ // Clone each pruned partition with the projection baked in.
613+ // The factory will receive proj_col_names and load only those vars.
614+ let projected_partitions: Vec < Arc < dyn PartitionStream > > = included_indices
615+ . iter ( )
616+ . map ( |& idx| {
617+ self . partitions [ idx] . 0 . clone_with_projection (
618+ Arc :: clone ( & proj_col_names) ,
619+ Arc :: clone ( & projected_schema) ,
620+ )
621+ } )
622+ . collect ( ) ;
623+
624+ // StreamingTable already has the projected schema — pass None for
625+ // projection so it doesn't wrap the stream in a redundant ProjectionExec.
626+ let streaming = StreamingTable :: try_new ( projected_schema, projected_partitions) ?;
627+ streaming. scan ( state, None , filters, limit) . await
628+ } else {
629+ // No projection pushdown — factory is called with None (loads all
630+ // columns). StreamingTable applies projection via ProjectionExec.
631+ let included_partitions: Vec < Arc < dyn PartitionStream > > = included_indices
632+ . iter ( )
633+ . map ( |& idx| self . partitions [ idx] . 0 . clone_as_stream ( ) )
634+ . collect ( ) ;
635+ let streaming = StreamingTable :: try_new ( Arc :: clone ( & self . schema ) , included_partitions) ?;
636+ streaming. scan ( state, projection, filters, limit) . await
637+ }
568638 }
569639}
570640
571641/// A partition stream that wraps a Python factory function that creates streams.
572642///
573643/// The factory is called lazily on each `execute()` invocation, allowing
574644/// the same table to be queried multiple times.
645+ ///
646+ /// When `projection` is set, the factory is called with that list of column
647+ /// names so that xarray only loads the requested data variables rather than
648+ /// materializing every variable in the dataset.
575649struct PyArrowStreamPartition {
576650 schema : SchemaRef ,
577- /// A Python callable (factory) that returns a fresh stream implementing `__arrow_c_stream__`.
578- /// Called on each execute() to create a new stream.
579- stream_factory : Py < PyAny > ,
651+ /// A Python callable (factory) that returns a fresh stream.
652+ /// Signature: `make_stream(projection_names: Optional[List[str]]) -> RecordBatchReader`
653+ ///
654+ /// Wrapped in `Arc` so `ProjectableStream::clone_with_projection` can share
655+ /// the same Python object across projected partitions without acquiring the
656+ /// GIL — only an atomic reference-count increment is needed.
657+ stream_factory : Arc < Py < PyAny > > ,
658+ /// Column names to pass to the factory. `None` means load all columns.
659+ /// Stored as `Arc<[String]>` so multiple projected clones share one allocation.
660+ projection : Option < Arc < [ String ] > > ,
580661}
581662
582663impl PyArrowStreamPartition {
583664 fn new ( stream_factory : Py < PyAny > , schema : SchemaRef ) -> Self {
584665 Self {
585666 schema,
586- stream_factory,
667+ stream_factory : Arc :: new ( stream_factory) ,
668+ projection : None ,
587669 }
588670 }
589671}
590672
673+ impl ProjectableStream for PyArrowStreamPartition {
674+ /// Return a new partition that emits only the given columns.
675+ ///
676+ /// Clones the factory `Arc` (atomic refcount increment, no GIL) so the
677+ /// same Python callable is shared across all projected partitions.
678+ fn clone_with_projection (
679+ & self ,
680+ projection : Arc < [ String ] > ,
681+ projected_schema : SchemaRef ,
682+ ) -> Arc < dyn PartitionStream > {
683+ Arc :: new ( Self {
684+ schema : projected_schema,
685+ stream_factory : Arc :: clone ( & self . stream_factory ) ,
686+ projection : Some ( projection) ,
687+ } )
688+ }
689+
690+ fn clone_as_stream ( & self ) -> Arc < dyn PartitionStream > {
691+ Arc :: new ( Self {
692+ schema : Arc :: clone ( & self . schema ) ,
693+ stream_factory : Arc :: clone ( & self . stream_factory ) ,
694+ projection : self . projection . clone ( ) ,
695+ } )
696+ }
697+ }
698+
591699impl Debug for PyArrowStreamPartition {
592700 fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
593701 f. debug_struct ( "PyArrowStreamPartition" )
@@ -604,8 +712,9 @@ impl PartitionStream for PyArrowStreamPartition {
604712 fn execute ( & self , _ctx : Arc < TaskContext > ) -> SendableRecordBatchStream {
605713 let schema = Arc :: clone ( & self . schema ) ;
606714
607- // Clone the factory with the GIL held
608- let factory = Python :: attach ( |py| self . stream_factory . clone_ref ( py) ) ;
715+ // Clone the factory Arc (no GIL needed) and the projection list.
716+ let factory = Arc :: clone ( & self . stream_factory ) ;
717+ let projection = self . projection . clone ( ) ;
609718
610719 // Create a lazy stream using try_stream! macro.
611720 // The GIL is acquired only for the duration of each Python call
@@ -614,9 +723,22 @@ impl PartitionStream for PyArrowStreamPartition {
614723 // executor a chance to poll other partition streams (which can then
615724 // acquire the GIL and make progress in parallel).
616725 let batch_stream = try_stream ! {
617- // Call factory to get the PyArrow RecordBatchReader
726+ // Call factory with the projection argument.
727+ // `projection` is either a Python list of column names or None
728+ // (load all columns). The factory always receives exactly one arg
729+ // so it can distinguish "no projection" from "empty projection".
618730 let reader: Py <PyAny > = Python :: attach( |py| {
619- factory. call0( py) . map_err( |e| {
731+ let proj_arg = match & projection {
732+ Some ( cols) => PyList :: new( py, cols. iter( ) . map( |s| s. as_str( ) ) )
733+ . map_err( |e| {
734+ DataFusionError :: Execution ( format!(
735+ "Failed to build projection list: {e}"
736+ ) )
737+ } ) ?
738+ . into_any( ) ,
739+ None => py. None ( ) . into_bound( py) . into_any( ) ,
740+ } ;
741+ factory. call1( py, ( proj_arg, ) ) . map_err( |e| {
620742 DataFusionError :: Execution ( format!( "Failed to call stream factory: {e}" ) )
621743 } )
622744 } ) ?;
@@ -748,7 +870,9 @@ impl LazyArrowStreamTable {
748870 // eliminating the per-partition Python::attach() calls of the old
749871 // three-list approach. Python can release each block dict, factory
750872 // closure, and metadata dict as soon as Rust has ingested them.
751- let mut partition_list: Vec < ( Arc < dyn PartitionStream > , PartitionMetadata ) > = Vec :: new ( ) ;
873+ // Stored as Arc<dyn ProjectableStream> so PrunableStreamingTable
874+ // is decoupled from PyArrowStreamPartition.
875+ let mut partition_list: Vec < ( Arc < dyn ProjectableStream > , PartitionMetadata ) > = Vec :: new ( ) ;
752876 for item_result in partitions. try_iter ( ) ? {
753877 let item = item_result?;
754878 let ( factory_obj, meta_obj) : ( Py < PyAny > , Py < PyAny > ) = item. extract ( ) . map_err ( |e| {
@@ -757,8 +881,8 @@ impl LazyArrowStreamTable {
757881 ) )
758882 } ) ?;
759883 let meta = convert_python_metadata_from_bound ( meta_obj. bind ( partitions. py ( ) ) ) ?;
760- let partition = Arc :: new ( PyArrowStreamPartition :: new ( factory_obj , schema_ref . clone ( ) ) )
761- as Arc < dyn PartitionStream > ;
884+ let partition: Arc < dyn ProjectableStream > =
885+ Arc :: new ( PyArrowStreamPartition :: new ( factory_obj , schema_ref . clone ( ) ) ) ;
762886 partition_list. push ( ( partition, meta) ) ;
763887 }
764888
0 commit comments