Skip to main content

laminar_sql/datafusion/exec/
mod.rs

1//! Streaming scan execution plan for `DataFusion`
2//!
3//! This module provides `StreamingScanExec`, a `DataFusion` execution plan
4//! that reads from a `StreamSource`. It serves as the leaf node in query
5//! plans for streaming data.
6
7use std::fmt::{Debug, Formatter};
8use std::sync::Arc;
9
10use arrow_schema::{SchemaRef, SortOptions};
11use datafusion::execution::{SendableRecordBatchStream, TaskContext};
12use datafusion::physical_expr::{
13    expressions::Column, EquivalenceProperties, LexOrdering, Partitioning, PhysicalSortExpr,
14};
15use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType, SchedulingType};
16use datafusion::physical_plan::{DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties};
17use datafusion_common::DataFusionError;
18use datafusion_expr::Expr;
19
20use super::source::{SortColumn, StreamSourceRef};
21
22/// A `DataFusion` execution plan that scans from a streaming source.
23///
24/// This is a leaf node in the query plan tree that pulls data from
25/// a `StreamSource` implementation. It handles projection and filter
26/// pushdown to the source when supported.
27///
28/// # Properties
29///
30/// - Single partition (streaming sources are typically not partitioned)
31/// - Unbounded execution mode (streaming)
32/// - No inherent ordering (unless specified by source)
33pub struct StreamingScanExec {
34    /// The streaming source to read from
35    source: StreamSourceRef,
36    /// Output schema (after projection)
37    schema: SchemaRef,
38    /// Column projection (None = all columns)
39    projection: Option<Vec<usize>>,
40    /// Filters pushed down to source
41    filters: Vec<Expr>,
42    /// Cached plan properties
43    properties: Arc<PlanProperties>,
44}
45
46impl StreamingScanExec {
47    /// Creates a new streaming scan execution plan.
48    ///
49    /// If the source declares an `output_ordering`, the plan's
50    /// `EquivalenceProperties` will include it so `DataFusion` can elide
51    /// `SortExec` for matching ORDER BY queries.
52    pub fn new(
53        source: StreamSourceRef,
54        projection: Option<Vec<usize>>,
55        filters: Vec<Expr>,
56    ) -> Self {
57        let source_schema = source.schema();
58        let source_ordering = source.output_ordering();
59
60        let schema = match &projection {
61            Some(indices) => {
62                let fields: Vec<_> = indices
63                    .iter()
64                    .map(|&i| source_schema.field(i).clone())
65                    .collect();
66                Arc::new(arrow_schema::Schema::new(fields))
67            }
68            None => source_schema,
69        };
70
71        let eq_properties = Self::build_equivalence_properties(&schema, source_ordering.as_deref());
72
73        // SchedulingType::NonCooperative causes DataFusion's EnsureCooperative
74        // optimizer rule to auto-wrap this leaf with CooperativeExec, which
75        // yields to the Tokio executor periodically.
76        let properties = Arc::new(
77            PlanProperties::new(
78                eq_properties,
79                Partitioning::UnknownPartitioning(1),
80                EmissionType::Incremental,
81                Boundedness::Unbounded {
82                    requires_infinite_memory: false,
83                },
84            )
85            .with_scheduling_type(SchedulingType::NonCooperative),
86        );
87
88        Self {
89            source,
90            schema,
91            projection,
92            filters,
93            properties,
94        }
95    }
96
97    /// Builds `EquivalenceProperties` with optional source ordering.
98    ///
99    /// Converts `SortColumn` declarations into `DataFusion` `PhysicalSortExpr`
100    /// entries. Only columns present in the output schema are included.
101    fn build_equivalence_properties(
102        schema: &SchemaRef,
103        ordering: Option<&[SortColumn]>,
104    ) -> EquivalenceProperties {
105        let mut eq = EquivalenceProperties::new(Arc::clone(schema));
106
107        if let Some(sort_columns) = ordering {
108            let sort_exprs: Vec<PhysicalSortExpr> = sort_columns
109                .iter()
110                .filter_map(|sc| {
111                    // Find column index in the output schema
112                    schema.index_of(&sc.name).ok().map(|idx| {
113                        PhysicalSortExpr::new(
114                            Arc::new(Column::new(&sc.name, idx)),
115                            SortOptions {
116                                descending: sc.descending,
117                                nulls_first: sc.nulls_first,
118                            },
119                        )
120                    })
121                })
122                .collect();
123
124            if !sort_exprs.is_empty() {
125                eq.add_ordering(sort_exprs);
126            }
127        }
128
129        eq
130    }
131
132    /// Returns the streaming source.
133    #[must_use]
134    pub fn source(&self) -> &StreamSourceRef {
135        &self.source
136    }
137
138    /// Returns the column projection.
139    #[must_use]
140    pub fn projection(&self) -> Option<&[usize]> {
141        self.projection.as_deref()
142    }
143
144    /// Returns the pushed-down filters.
145    #[must_use]
146    pub fn filters(&self) -> &[Expr] {
147        &self.filters
148    }
149}
150
151impl Debug for StreamingScanExec {
152    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
153        f.debug_struct("StreamingScanExec")
154            .field("source", &self.source)
155            .field("schema", &self.schema)
156            .field("projection", &self.projection)
157            .field("filters", &self.filters)
158            .finish_non_exhaustive()
159    }
160}
161
162impl DisplayAs for StreamingScanExec {
163    fn fmt_as(&self, t: DisplayFormatType, f: &mut Formatter<'_>) -> std::fmt::Result {
164        match t {
165            DisplayFormatType::Default | DisplayFormatType::Verbose => {
166                write!(f, "StreamingScanExec: ")?;
167                if let Some(proj) = &self.projection {
168                    write!(f, "projection=[{proj:?}]")?;
169                } else {
170                    write!(f, "projection=[*]")?;
171                }
172                if !self.filters.is_empty() {
173                    write!(f, ", filters={:?}", self.filters)?;
174                }
175                Ok(())
176            }
177            DisplayFormatType::TreeRender => {
178                write!(f, "StreamingScanExec")
179            }
180        }
181    }
182}
183
184impl ExecutionPlan for StreamingScanExec {
185    fn as_any(&self) -> &dyn std::any::Any {
186        self
187    }
188
189    fn name(&self) -> &'static str {
190        "StreamingScanExec"
191    }
192
193    fn schema(&self) -> SchemaRef {
194        Arc::clone(&self.schema)
195    }
196
197    fn properties(&self) -> &Arc<PlanProperties> {
198        &self.properties
199    }
200
201    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
202        // Leaf node - no children
203        vec![]
204    }
205
206    fn with_new_children(
207        self: Arc<Self>,
208        children: Vec<Arc<dyn ExecutionPlan>>,
209    ) -> Result<Arc<dyn ExecutionPlan>, DataFusionError> {
210        if children.is_empty() {
211            // No changes needed for leaf node
212            Ok(self)
213        } else {
214            Err(DataFusionError::Plan(
215                "StreamingScanExec cannot have children".to_string(),
216            ))
217        }
218    }
219
220    fn execute(
221        &self,
222        partition: usize,
223        _context: Arc<TaskContext>,
224    ) -> Result<SendableRecordBatchStream, DataFusionError> {
225        if partition != 0 {
226            return Err(DataFusionError::Plan(format!(
227                "StreamingScanExec only supports partition 0, got {partition}"
228            )));
229        }
230
231        self.source
232            .stream(self.projection.clone(), self.filters.clone())
233    }
234}
235
236// Required for `DataFusion` to use this execution plan
237impl datafusion::physical_plan::ExecutionPlanProperties for StreamingScanExec {
238    fn output_partitioning(&self) -> &Partitioning {
239        self.properties.output_partitioning()
240    }
241
242    fn output_ordering(&self) -> Option<&LexOrdering> {
243        self.properties.output_ordering()
244    }
245
246    fn boundedness(&self) -> Boundedness {
247        Boundedness::Unbounded {
248            requires_infinite_memory: false,
249        }
250    }
251
252    fn pipeline_behavior(&self) -> EmissionType {
253        EmissionType::Incremental
254    }
255
256    fn equivalence_properties(&self) -> &EquivalenceProperties {
257        self.properties.equivalence_properties()
258    }
259}
260
261#[cfg(test)]
262mod tests;