Skip to main content

uqa_execution/
physical.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Volcano `PhysicalOperator` trait shared by every operator in this crate.
8
9use thiserror::Error;
10
11use crate::batch::{Batch, RowSchema};
12
13/// A leading physical row-order property carried between operators.
14///
15/// `position` is a logical position in the operator's output schema. Positional tracking keeps duplicate labels and structured qualified identities distinct. `nulls_first` is irrelevant when `nullable` is false, which lets a primary-key scan satisfy either explicit NULLS placement without adding a redundant sort.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct PhysicalOrder {
18    pub position: usize,
19    pub descending: bool,
20    pub nulls_first: Option<bool>,
21    pub nullable: bool,
22}
23
24/// Operator-pipeline error type. Wraps SQL evaluation errors so call
25/// sites do not need to juggle two error enums.
26#[derive(Debug, Error)]
27pub enum ExecError {
28    #[error("execution error: {0}")]
29    Other(String),
30    #[error("SQL error: {0}")]
31    SQL(#[from] uqa_sql::SQLError),
32}
33
34pub type ExecResult<T> = std::result::Result<T, ExecError>;
35
36/// Preserve the primary execution result while still surfacing a cleanup
37/// failure. `Result::and` discards the cleanup error whenever both operations
38/// fail, which makes file/child-close failures disappear behind the original
39/// operator error.
40pub(crate) fn with_cleanup<T>(
41    primary: ExecResult<T>,
42    cleanup: ExecResult<()>,
43    cleanup_context: &str,
44) -> ExecResult<T> {
45    match (primary, cleanup) {
46        (Ok(value), Ok(())) => Ok(value),
47        (Ok(_), Err(cleanup_error)) => Err(cleanup_error),
48        (Err(primary_error), Ok(())) => Err(primary_error),
49        (Err(primary_error), Err(cleanup_error)) => Err(ExecError::Other(format!(
50            "{primary_error}; {cleanup_context}: {cleanup_error}"
51        ))),
52    }
53}
54
55/// Volcano-style streaming operator. Operators form a tree; each
56/// operator pulls from its children inside `next` and emits a
57/// [`Batch`] until the input is exhausted.
58///
59/// Pipeline lifecycle:
60///
61/// 1. `open` -- bind state, open child operators, allocate buffers.
62/// 2. `next` -- pull the next [`Batch`]; return `None` to terminate.
63/// 3. `close` -- release buffers and close child operators.
64///
65/// Blocking operators (sort / hash-aggregate) materialise their input
66/// during `open`; pipelined operators (filter / project / limit) emit
67/// batches as they arrive.
68pub trait PhysicalOperator: Send {
69    /// Complete logical-to-physical row layout emitted by this operator. Every [`Batch`] returned by [`Self::next`] must carry this exact schema; operators must reject a child that violates that invariant.
70    fn row_schema(&self) -> &RowSchema;
71
72    /// Schema column names in logical output order.
73    fn schema(&self) -> &[String] {
74        self.row_schema().columns()
75    }
76
77    /// Planner/runtime cardinality estimate for choosing physical strategies.
78    /// `None` means the operator cannot provide a useful estimate. The value
79    /// is advisory rather than a correctness bound.
80    fn estimated_cardinality(&self) -> Option<u64> {
81        None
82    }
83
84    /// Leading output ordering known to be preserved by this operator.
85    fn output_ordering(&self) -> &[PhysicalOrder] {
86        &[]
87    }
88
89    /// Let a leaf consume its native projected rows directly into an
90    /// aggregate executor. Returning `false` promises that no input was
91    /// consumed, so the caller can fall back to ordinary `Batch` pulls.
92    fn consume_into_aggregate(
93        &mut self,
94        _executor: &mut dyn crate::relational::AggregateExecutor,
95    ) -> ExecResult<bool> {
96        Ok(false)
97    }
98
99    fn open(&mut self) -> ExecResult<()>;
100    fn next(&mut self) -> ExecResult<Option<Batch>>;
101    fn close(&mut self) -> ExecResult<()>;
102}
103
104/// Return whether `actual` has `required` as an ordering prefix.
105pub fn ordering_satisfies(actual: &[PhysicalOrder], required: &[PhysicalOrder]) -> bool {
106    actual.len() >= required.len()
107        && actual.iter().zip(required).all(|(actual, required)| {
108            actual.position == required.position
109                && actual.descending == required.descending
110                && (!actual.nullable
111                    || actual.nulls_first == required.nulls_first
112                    || required.nulls_first.is_none())
113        })
114}
115
116/// Resolve a simple ordering expression to one logical position without collapsing duplicate or qualified SQL identities into a display label.
117pub fn order_expression_position(
118    schema: &RowSchema,
119    expression: &crate::ScalarExpr,
120) -> Option<usize> {
121    match expression {
122        crate::ScalarExpr::Column(column) => schema.unqualified_position(column),
123        crate::ScalarExpr::Position(position) => (*position < schema.len()).then_some(*position),
124        crate::ScalarExpr::QualifiedColumn { qualifier, column } => {
125            schema.qualified_position(qualifier, column)
126        }
127        _ => None,
128    }
129}
130
131/// Borrowing iterator over a physical operator. Construction opens the
132/// pipeline; exhaustion and execution errors close it exactly once. Dropping
133/// an unfinished cursor performs best-effort cleanup.
134pub struct OperatorBatchCursor<'operator> {
135    operator: &'operator mut dyn PhysicalOperator,
136    finished: bool,
137}
138
139impl<'operator> OperatorBatchCursor<'operator> {
140    pub fn open(operator: &'operator mut dyn PhysicalOperator) -> ExecResult<Self> {
141        if let Err(open_error) = operator.open() {
142            return match operator.close() {
143                Ok(()) => Err(open_error),
144                Err(close_error) => Err(ExecError::Other(format!(
145                    "{open_error}; operator close after open failure also failed: {close_error}"
146                ))),
147            };
148        }
149        Ok(Self {
150            operator,
151            finished: false,
152        })
153    }
154
155    fn finish(&mut self) -> ExecResult<()> {
156        if self.finished {
157            return Ok(());
158        }
159        self.finished = true;
160        self.operator.close()
161    }
162}
163
164impl Iterator for OperatorBatchCursor<'_> {
165    type Item = ExecResult<Batch>;
166
167    fn next(&mut self) -> Option<Self::Item> {
168        if self.finished {
169            return None;
170        }
171        match self.operator.next() {
172            Ok(Some(batch)) => Some(Ok(batch)),
173            Ok(None) => match self.finish() {
174                Ok(()) => None,
175                Err(error) => Some(Err(error)),
176            },
177            Err(next_error) => {
178                let close = self.finish();
179                Some(with_cleanup(
180                    Err(next_error),
181                    close,
182                    "operator close after execution failure also failed",
183                ))
184            }
185        }
186    }
187}
188
189impl Drop for OperatorBatchCursor<'_> {
190    fn drop(&mut self) {
191        let _ = self.finish();
192    }
193}
194
195/// Convenience: collect every batch from `op` until exhaustion. The
196/// operator is `open`ed and `close`d for the caller. Useful for tests
197/// and for callers that do not need streaming behaviour.
198pub fn run_to_batches(op: &mut dyn PhysicalOperator) -> ExecResult<Vec<Batch>> {
199    OperatorBatchCursor::open(op)?.collect()
200}
201
202/// Run the operator and concatenate all output batches into a single
203/// flat row vector. The schema of the first batch is the result schema.
204pub fn run_to_rows(
205    op: &mut dyn PhysicalOperator,
206) -> ExecResult<(Vec<String>, Vec<uqa_sql::ResultRow>)> {
207    let schema = op.schema().to_vec();
208    let mut rows: Vec<uqa_sql::ResultRow> = Vec::new();
209    for batch in OperatorBatchCursor::open(op)? {
210        let batch = batch?;
211        rows.extend(batch.into_result_rows());
212    }
213    Ok((schema, rows))
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    struct FailingOperator {
221        fail_open: bool,
222        fail_close: bool,
223        closed: bool,
224    }
225
226    impl PhysicalOperator for FailingOperator {
227        fn row_schema(&self) -> &RowSchema {
228            static SCHEMA: std::sync::OnceLock<RowSchema> = std::sync::OnceLock::new();
229            SCHEMA.get_or_init(RowSchema::default)
230        }
231
232        fn open(&mut self) -> ExecResult<()> {
233            if self.fail_open {
234                Err(ExecError::Other("open failed".into()))
235            } else {
236                Ok(())
237            }
238        }
239
240        fn next(&mut self) -> ExecResult<Option<Batch>> {
241            Err(ExecError::Other("next failed".into()))
242        }
243
244        fn close(&mut self) -> ExecResult<()> {
245            self.closed = true;
246            if self.fail_close {
247                Err(ExecError::Other("close failed".into()))
248            } else {
249                Ok(())
250            }
251        }
252    }
253
254    #[test]
255    fn runner_closes_after_open_and_next_failures() {
256        let mut open = FailingOperator {
257            fail_open: true,
258            fail_close: false,
259            closed: false,
260        };
261        assert!(run_to_batches(&mut open)
262            .unwrap_err()
263            .to_string()
264            .contains("open failed"));
265        assert!(open.closed);
266
267        let mut next = FailingOperator {
268            fail_open: false,
269            fail_close: false,
270            closed: false,
271        };
272        assert!(run_to_batches(&mut next)
273            .unwrap_err()
274            .to_string()
275            .contains("next failed"));
276        assert!(next.closed);
277    }
278
279    #[test]
280    fn runner_reports_execution_and_cleanup_failures() {
281        let mut operator = FailingOperator {
282            fail_open: false,
283            fail_close: true,
284            closed: false,
285        };
286        let error = run_to_batches(&mut operator).unwrap_err().to_string();
287        assert!(error.contains("next failed"), "{error}");
288        assert!(error.contains("close failed"), "{error}");
289        assert!(operator.closed);
290    }
291
292    #[test]
293    fn cleanup_combiner_preserves_both_errors() {
294        let error = with_cleanup::<()>(
295            Err(ExecError::Other("primary".into())),
296            Err(ExecError::Other("cleanup".into())),
297            "cleanup failed",
298        )
299        .unwrap_err()
300        .to_string();
301        assert!(error.contains("primary"), "{error}");
302        assert!(error.contains("cleanup"), "{error}");
303    }
304
305    #[test]
306    fn dropping_cursor_closes_an_unfinished_pipeline() {
307        let mut operator = FailingOperator {
308            fail_open: false,
309            fail_close: false,
310            closed: false,
311        };
312        {
313            let _cursor = OperatorBatchCursor::open(&mut operator).unwrap();
314        }
315        assert!(operator.closed);
316    }
317
318    #[test]
319    fn ordering_positions_keep_duplicate_structured_identities_distinct() {
320        let schema = RowSchema::with_identities(
321            vec!["id".into(), "id".into()],
322            vec![
323                crate::ColumnIdentity::qualified("left", "id"),
324                crate::ColumnIdentity::qualified("right", "id"),
325            ],
326            vec![None, None],
327        );
328        assert_eq!(
329            order_expression_position(&schema, &crate::ScalarExpr::qualified_column("left", "id")),
330            Some(0)
331        );
332        assert_eq!(
333            order_expression_position(&schema, &crate::ScalarExpr::qualified_column("right", "id")),
334            Some(1)
335        );
336        assert_eq!(
337            order_expression_position(&schema, &crate::ScalarExpr::Column("id".into())),
338            None
339        );
340        let actual = [PhysicalOrder {
341            position: 0,
342            descending: false,
343            nulls_first: None,
344            nullable: false,
345        }];
346        let required = [PhysicalOrder {
347            position: 1,
348            descending: false,
349            nulls_first: Some(false),
350            nullable: true,
351        }];
352        assert!(!ordering_satisfies(&actual, &required));
353    }
354}