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/// Direction requested by a scrollable query consumer.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum PhysicalScanDirection {
39    Forward,
40    Backward,
41}
42
43/// How an operator participates in a backwards-capable pipeline.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum BackwardScanSupport {
46    /// The operator cannot preserve `PostgreSQL` backwards-scan semantics. A scrollable cursor must materialize the completed plan above it.
47    Unsupported,
48    /// The operator's output is a semantic materialization boundary and may be placed in a directional spool before parent expressions run.
49    Materialize,
50    /// The operator natively accepts [`PhysicalScanDirection`] and can rewind without rebuilding its semantic state.
51    Native,
52}
53
54/// Preserve the primary execution result while still surfacing a cleanup
55/// failure. `Result::and` discards the cleanup error whenever both operations
56/// fail, which makes file/child-close failures disappear behind the original
57/// operator error.
58pub(crate) fn with_cleanup<T>(
59    primary: ExecResult<T>,
60    cleanup: ExecResult<()>,
61    cleanup_context: &str,
62) -> ExecResult<T> {
63    match (primary, cleanup) {
64        (Ok(value), Ok(())) => Ok(value),
65        (Ok(_), Err(cleanup_error)) => Err(cleanup_error),
66        (Err(primary_error), Ok(())) => Err(primary_error),
67        (Err(primary_error), Err(cleanup_error)) => Err(ExecError::Other(format!(
68            "{primary_error}; {cleanup_context}: {cleanup_error}"
69        ))),
70    }
71}
72
73/// Volcano-style streaming operator. Operators form a tree; each
74/// operator pulls from its children inside `next` and emits a
75/// [`Batch`] until the input is exhausted.
76///
77/// Pipeline lifecycle:
78///
79/// 1. `open` -- bind state, open child operators, allocate buffers.
80/// 2. `next` -- pull the next [`Batch`]; return `None` to terminate.
81/// 3. `close` -- release buffers and close child operators.
82///
83/// Blocking operators (sort / hash-aggregate) materialise their input
84/// during `open`; pipelined operators (filter / project / limit) emit
85/// batches as they arrive.
86pub trait PhysicalOperator: Send {
87    /// 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.
88    fn row_schema(&self) -> &RowSchema;
89
90    /// Schema column names in logical output order.
91    fn schema(&self) -> &[String] {
92        self.row_schema().columns()
93    }
94
95    /// Planner/runtime cardinality estimate for choosing physical strategies.
96    /// `None` means the operator cannot provide a useful estimate. The value
97    /// is advisory rather than a correctness bound.
98    fn estimated_cardinality(&self) -> Option<u64> {
99        None
100    }
101
102    /// Leading output ordering known to be preserved by this operator.
103    fn output_ordering(&self) -> &[PhysicalOrder] {
104        &[]
105    }
106
107    /// Let a leaf consume its native projected rows directly into an
108    /// aggregate executor. Returning `false` promises that no input was
109    /// consumed, so the caller can fall back to ordinary `Batch` pulls.
110    fn consume_into_aggregate(
111        &mut self,
112        _executor: &mut dyn crate::relational::AggregateExecutor,
113    ) -> ExecResult<bool> {
114        Ok(false)
115    }
116
117    /// Describe backwards-scan support without changing the operator tree. The default is deliberately conservative so a scrollable cursor freezes the complete output of unclassified plans.
118    fn backward_scan_support(&self) -> BackwardScanSupport {
119        BackwardScanSupport::Unsupported
120    }
121
122    fn open(&mut self) -> ExecResult<()>;
123    fn next(&mut self) -> ExecResult<Option<Batch>>;
124    /// Pull one directional batch. Native backwards-capable pipelines keep batches to one row so a consumer can reverse direction between adjacent fetches.
125    fn next_direction(&mut self, direction: PhysicalScanDirection) -> ExecResult<Option<Batch>> {
126        match direction {
127            PhysicalScanDirection::Forward => self.next(),
128            PhysicalScanDirection::Backward => Err(ExecError::Other(
129                "physical operator does not support backwards scanning".into(),
130            )),
131        }
132    }
133    /// Rewind a native backwards-capable pipeline without recomputing rows held by semantic materialization boundaries.
134    fn rewind(&mut self) -> ExecResult<()> {
135        Err(ExecError::Other(
136            "physical operator does not support rewind".into(),
137        ))
138    }
139    fn close(&mut self) -> ExecResult<()>;
140}
141
142/// Return whether `actual` has `required` as an ordering prefix.
143pub fn ordering_satisfies(actual: &[PhysicalOrder], required: &[PhysicalOrder]) -> bool {
144    actual.len() >= required.len()
145        && actual.iter().zip(required).all(|(actual, required)| {
146            actual.position == required.position
147                && actual.descending == required.descending
148                && (!actual.nullable
149                    || actual.nulls_first == required.nulls_first
150                    || required.nulls_first.is_none())
151        })
152}
153
154/// Resolve a simple ordering expression to one logical position without collapsing duplicate or qualified SQL identities into a display label.
155pub fn order_expression_position(
156    schema: &RowSchema,
157    expression: &crate::ScalarExpr,
158) -> Option<usize> {
159    match expression {
160        crate::ScalarExpr::Column(column) => schema.unqualified_position(column),
161        crate::ScalarExpr::Position(position) => (*position < schema.len()).then_some(*position),
162        crate::ScalarExpr::QualifiedColumn { qualifier, column } => {
163            schema.qualified_position(qualifier, column)
164        }
165        _ => None,
166    }
167}
168
169/// Borrowing iterator over a physical operator. Construction opens the
170/// pipeline; exhaustion and execution errors close it exactly once. Dropping
171/// an unfinished cursor performs best-effort cleanup.
172pub struct OperatorBatchCursor<'operator> {
173    operator: &'operator mut dyn PhysicalOperator,
174    finished: bool,
175}
176
177impl<'operator> OperatorBatchCursor<'operator> {
178    pub fn open(operator: &'operator mut dyn PhysicalOperator) -> ExecResult<Self> {
179        if let Err(open_error) = operator.open() {
180            return match operator.close() {
181                Ok(()) => Err(open_error),
182                Err(close_error) => Err(ExecError::Other(format!(
183                    "{open_error}; operator close after open failure also failed: {close_error}"
184                ))),
185            };
186        }
187        Ok(Self {
188            operator,
189            finished: false,
190        })
191    }
192
193    fn finish(&mut self) -> ExecResult<()> {
194        if self.finished {
195            return Ok(());
196        }
197        self.finished = true;
198        self.operator.close()
199    }
200}
201
202impl Iterator for OperatorBatchCursor<'_> {
203    type Item = ExecResult<Batch>;
204
205    fn next(&mut self) -> Option<Self::Item> {
206        if self.finished {
207            return None;
208        }
209        match self.operator.next() {
210            Ok(Some(batch)) => Some(Ok(batch)),
211            Ok(None) => match self.finish() {
212                Ok(()) => None,
213                Err(error) => Some(Err(error)),
214            },
215            Err(next_error) => {
216                let close = self.finish();
217                Some(with_cleanup(
218                    Err(next_error),
219                    close,
220                    "operator close after execution failure also failed",
221                ))
222            }
223        }
224    }
225}
226
227impl Drop for OperatorBatchCursor<'_> {
228    fn drop(&mut self) {
229        let _ = self.finish();
230    }
231}
232
233/// Convenience: collect every batch from `op` until exhaustion. The
234/// operator is `open`ed and `close`d for the caller. Useful for tests
235/// and for callers that do not need streaming behaviour.
236pub fn run_to_batches(op: &mut dyn PhysicalOperator) -> ExecResult<Vec<Batch>> {
237    OperatorBatchCursor::open(op)?.collect()
238}
239
240/// Run the operator and concatenate all output batches into a single
241/// flat row vector. The schema of the first batch is the result schema.
242pub fn run_to_rows(
243    op: &mut dyn PhysicalOperator,
244) -> ExecResult<(Vec<String>, Vec<uqa_sql::ResultRow>)> {
245    let schema = op.schema().to_vec();
246    let mut rows: Vec<uqa_sql::ResultRow> = Vec::new();
247    for batch in OperatorBatchCursor::open(op)? {
248        let batch = batch?;
249        rows.extend(batch.into_result_rows());
250    }
251    Ok((schema, rows))
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    struct FailingOperator {
259        fail_open: bool,
260        fail_close: bool,
261        closed: bool,
262    }
263
264    impl PhysicalOperator for FailingOperator {
265        fn row_schema(&self) -> &RowSchema {
266            static SCHEMA: std::sync::OnceLock<RowSchema> = std::sync::OnceLock::new();
267            SCHEMA.get_or_init(RowSchema::default)
268        }
269
270        fn open(&mut self) -> ExecResult<()> {
271            if self.fail_open {
272                Err(ExecError::Other("open failed".into()))
273            } else {
274                Ok(())
275            }
276        }
277
278        fn next(&mut self) -> ExecResult<Option<Batch>> {
279            Err(ExecError::Other("next failed".into()))
280        }
281
282        fn close(&mut self) -> ExecResult<()> {
283            self.closed = true;
284            if self.fail_close {
285                Err(ExecError::Other("close failed".into()))
286            } else {
287                Ok(())
288            }
289        }
290    }
291
292    #[test]
293    fn runner_closes_after_open_and_next_failures() {
294        let mut open = FailingOperator {
295            fail_open: true,
296            fail_close: false,
297            closed: false,
298        };
299        assert!(run_to_batches(&mut open)
300            .unwrap_err()
301            .to_string()
302            .contains("open failed"));
303        assert!(open.closed);
304
305        let mut next = FailingOperator {
306            fail_open: false,
307            fail_close: false,
308            closed: false,
309        };
310        assert!(run_to_batches(&mut next)
311            .unwrap_err()
312            .to_string()
313            .contains("next failed"));
314        assert!(next.closed);
315    }
316
317    #[test]
318    fn runner_reports_execution_and_cleanup_failures() {
319        let mut operator = FailingOperator {
320            fail_open: false,
321            fail_close: true,
322            closed: false,
323        };
324        let error = run_to_batches(&mut operator).unwrap_err().to_string();
325        assert!(error.contains("next failed"), "{error}");
326        assert!(error.contains("close failed"), "{error}");
327        assert!(operator.closed);
328    }
329
330    #[test]
331    fn cleanup_combiner_preserves_both_errors() {
332        let error = with_cleanup::<()>(
333            Err(ExecError::Other("primary".into())),
334            Err(ExecError::Other("cleanup".into())),
335            "cleanup failed",
336        )
337        .unwrap_err()
338        .to_string();
339        assert!(error.contains("primary"), "{error}");
340        assert!(error.contains("cleanup"), "{error}");
341    }
342
343    #[test]
344    fn dropping_cursor_closes_an_unfinished_pipeline() {
345        let mut operator = FailingOperator {
346            fail_open: false,
347            fail_close: false,
348            closed: false,
349        };
350        {
351            let _cursor = OperatorBatchCursor::open(&mut operator).unwrap();
352        }
353        assert!(operator.closed);
354    }
355
356    #[test]
357    fn ordering_positions_keep_duplicate_structured_identities_distinct() {
358        let schema = RowSchema::with_identities(
359            vec!["id".into(), "id".into()],
360            vec![
361                crate::ColumnIdentity::qualified("left", "id"),
362                crate::ColumnIdentity::qualified("right", "id"),
363            ],
364            vec![None, None],
365        );
366        assert_eq!(
367            order_expression_position(&schema, &crate::ScalarExpr::qualified_column("left", "id")),
368            Some(0)
369        );
370        assert_eq!(
371            order_expression_position(&schema, &crate::ScalarExpr::qualified_column("right", "id")),
372            Some(1)
373        );
374        assert_eq!(
375            order_expression_position(&schema, &crate::ScalarExpr::Column("id".into())),
376            None
377        );
378        let actual = [PhysicalOrder {
379            position: 0,
380            descending: false,
381            nulls_first: None,
382            nullable: false,
383        }];
384        let required = [PhysicalOrder {
385            position: 1,
386            descending: false,
387            nulls_first: Some(false),
388            nullable: true,
389        }];
390        assert!(!ordering_satisfies(&actual, &required));
391    }
392}