Skip to main content

linkrs_query/executor/streaming/operators/
unary_operator.rs

1use std::sync::Arc;
2
3use crate::executor::expression::evaluator::compiled::{compiled_eval_enabled, CompiledExpr};
4use crate::executor::expression::evaluator::ExpressionEvaluator;
5use crate::executor::streaming::chunk::DataChunk;
6use crate::executor::streaming::executor::StreamingExecutor;
7use crate::executor::streaming::executor::ValueRowContext;
8use crate::executor::streaming::operators::source_operator::OperatorConfig;
9use crate::executor::streaming::runtime::ExecutionRuntime;
10use crate::executor::streaming::slot::SlotLayout;
11use crate::executor::streaming::subquery::{EvalEnv, SubqueryExecutor};
12use linkrs_core::error::QueryError;
13use linkrs_core::types::expr::Expression;
14use linkrs_core::Value;
15
16mod append_vertices;
17mod assign;
18mod dedup;
19mod filter;
20mod flatten;
21mod limit;
22mod project;
23mod sample;
24mod unwind;
25
26#[derive(Debug, Default)]
27pub struct UnaryOperatorState {
28    pub env: EvalEnv,
29    /// Lazily-compiled Filter predicate (compiled on the first chunk whose
30    /// layout binds the expression's slots; reused for all later chunks).
31    pub compiled_predicate: Option<CompiledExpr>,
32    /// Lazily-compiled Project output expressions, one per output column.
33    pub compiled_project: Option<Vec<CompiledExpr>>,
34}
35
36#[derive(Debug)]
37pub enum UnaryOperatorKind {
38    Filter {
39        predicate: Expression,
40        state: UnaryOperatorState,
41    },
42    Project {
43        output_expressions: Vec<Expression>,
44        output_col_names: Vec<String>,
45        state: UnaryOperatorState,
46    },
47    Limit {
48        offset: u32,
49        limit: u32,
50        skipped: u32,
51        consumed: u32,
52    },
53    Dedup {
54        seen_rows: std::collections::HashSet<Vec<Value>>,
55    },
56    Assign {
57        assignments: Vec<(String, Expression)>,
58        state: UnaryOperatorState,
59    },
60    Unwind {
61        unwind_column: String,
62        list_expression: Option<Expression>,
63        col_index: Option<usize>,
64        layout: Option<Arc<SlotLayout>>,
65        all_rows: Vec<Vec<Value>>,
66        /// Multiplicity of the chunk `all_rows` was taken from: every
67        /// emitted unwound row still occurs this many times.
68        input_multiplicity: u64,
69        current_row_index: usize,
70        current_unwind_index: usize,
71        input_done: bool,
72    },
73    AppendVertices {
74        entity_var: String,
75        entity_expr: Expression,
76        prop_names: Vec<Arc<str>>,
77        tag: String,
78        storage: Option<Arc<parking_lot::RwLock<dyn crate::storage::QueryStorage>>>,
79        space_name: String,
80        state: UnaryOperatorState,
81    },
82    Sample {
83        count: u64,
84        consumed: u64,
85    },
86    Flatten {
87        group_pos: u32,
88        group_columns: Vec<String>,
89        expected_groups: Option<u32>,
90        current_idx: usize,
91        size_to_flatten: usize,
92        saved_sel_vector: Option<Vec<usize>>,
93        buffered_chunk: Option<DataChunk>,
94        /// Rows emitted per output chunk. Defaults to the vectorized morsel
95        /// size so the batched flatten path produces one chunk per input
96        /// morsel; tests may set `1` for the single-row path.
97        batch_size: usize,
98    },
99}
100
101/// Unary operator.
102///
103/// Wraps [`UnaryOperatorKind`] with the runtime context injected at `open()`.
104/// Lifecycle state is owned exclusively by the executor; operators never
105/// write it.
106#[derive(Debug)]
107pub struct UnaryOperator {
108    pub kind: UnaryOperatorKind,
109    pub runtime: Option<Arc<ExecutionRuntime>>,
110    pub output_layout: Arc<SlotLayout>,
111    pub config: OperatorConfig,
112}
113
114impl UnaryOperatorKind {
115    /// Plan level factorization group this flatten replays, if any.
116    pub fn flatten_group_pos(&self) -> Option<u32> {
117        match self {
118            UnaryOperatorKind::Flatten { group_pos, .. } => Some(*group_pos),
119            _ => None,
120        }
121    }
122}
123
124impl UnaryOperator {
125    /// Create a UnaryOperator with fresh mutable state from an immutable spec.
126    pub fn from_spec(spec: &super::spec::UnarySpec, output_layout: Arc<SlotLayout>) -> Self {
127        let state = UnaryOperatorState::default();
128        let kind = match spec {
129            super::spec::UnarySpec::Filter {
130                predicate,
131                subquery_runners: _,
132            } => UnaryOperatorKind::Filter {
133                predicate: predicate.clone(),
134                state,
135            },
136            super::spec::UnarySpec::Project {
137                output_expressions,
138                output_col_names,
139                ..
140            } => UnaryOperatorKind::Project {
141                output_expressions: output_expressions.clone(),
142                output_col_names: output_col_names.clone(),
143                state,
144            },
145            super::spec::UnarySpec::Limit { offset, limit } => UnaryOperatorKind::Limit {
146                offset: *offset,
147                limit: *limit,
148                skipped: 0,
149                consumed: 0,
150            },
151            super::spec::UnarySpec::Assign {
152                assignments,
153                subquery_runners: _,
154            } => UnaryOperatorKind::Assign {
155                assignments: assignments.clone(),
156                state,
157            },
158            super::spec::UnarySpec::Unwind {
159                unwind_column,
160                list_expression,
161            } => UnaryOperatorKind::Unwind {
162                unwind_column: unwind_column.clone(),
163                list_expression: list_expression.clone(),
164                col_index: None,
165                layout: None,
166                all_rows: Vec::new(),
167                input_multiplicity: 1,
168                current_row_index: 0,
169                current_unwind_index: 0,
170                input_done: false,
171            },
172            super::spec::UnarySpec::AppendVertices {
173                space_name,
174                tag,
175                entity_var,
176                entity_expr,
177                prop_names,
178            } => UnaryOperatorKind::AppendVertices {
179                entity_var: entity_var.clone(),
180                entity_expr: entity_expr.clone(),
181                prop_names: prop_names
182                    .iter()
183                    .map(|name| Arc::from(name.as_str()))
184                    .collect(),
185                tag: tag.clone(),
186                storage: None,
187                space_name: space_name.clone(),
188                state,
189            },
190            super::spec::UnarySpec::Sample { count } => UnaryOperatorKind::Sample {
191                count: *count,
192                consumed: 0,
193            },
194            super::spec::UnarySpec::Flatten {
195                group_pos,
196                group_columns,
197                expected_groups,
198            } => UnaryOperatorKind::Flatten {
199                group_pos: *group_pos,
200                group_columns: group_columns.clone(),
201                expected_groups: *expected_groups,
202                current_idx: 0,
203                size_to_flatten: 0,
204                saved_sel_vector: None,
205                buffered_chunk: None,
206                batch_size:
207                    crate::executor::streaming::operators::flatten::DEFAULT_FLATTEN_BATCH_SIZE,
208            },
209        };
210        Self::new(kind, output_layout)
211    }
212
213    pub fn new(kind: UnaryOperatorKind, output_layout: Arc<SlotLayout>) -> Self {
214        Self {
215            kind,
216            runtime: None,
217            output_layout,
218            config: OperatorConfig::default(),
219        }
220    }
221
222    /// Inject the runtime and execution config (called once by the executor
223    /// before this operator produces any data).
224    pub fn inject_context(
225        &mut self,
226        runtime: Option<&Arc<ExecutionRuntime>>,
227        config: OperatorConfig,
228    ) {
229        if let Some(rt) = runtime {
230            self.runtime = Some(rt.clone());
231        }
232        self.config = config;
233    }
234
235    /// Inject the hosting operator's expression-level subquery executor into
236    /// the Filter/Project/Assign state. Called by the materializer
237    /// after `from_spec`; no-op for the other unary kinds.
238    pub fn set_subquery_executor(&mut self, executor: Arc<SubqueryExecutor>) {
239        match &mut self.kind {
240            UnaryOperatorKind::Filter { state, .. }
241            | UnaryOperatorKind::Project { state, .. }
242            | UnaryOperatorKind::Assign { state, .. } => {
243                state.env.subquery_executor = Some(executor);
244            }
245            _ => {}
246        }
247    }
248
249    pub fn open(&mut self, input: &mut StreamingExecutor) -> Result<(), QueryError> {
250        let params = self.runtime.as_ref().and_then(|rt| rt.parameter_values());
251        let session_variables = self
252            .runtime
253            .as_ref()
254            .and_then(|rt| rt.session_variable_values());
255        let sequence_manager = self.runtime.as_ref().and_then(|rt| rt.sequence_manager());
256        let storage = self.runtime.as_ref().and_then(|rt| rt.storage.clone());
257        match &mut self.kind {
258            UnaryOperatorKind::Filter { state, .. }
259            | UnaryOperatorKind::Project { state, .. }
260            | UnaryOperatorKind::Assign { state, .. }
261            | UnaryOperatorKind::AppendVertices { state, .. } => {
262                state.env.params = params;
263                state.env.session_variables = session_variables;
264                state.env.sequence_manager = sequence_manager;
265            }
266            _ => {}
267        }
268        if let UnaryOperatorKind::AppendVertices {
269            storage: target, ..
270        } = &mut self.kind
271        {
272            *target = storage;
273        }
274        // The streaming engine stores flat row batches only, so flatten
275        // replays rows instead of expanding a nested layout. The
276        // `expected_groups` count snapshotted at rewrite time makes the
277        // plan position checkable anyway: a stale `group_pos` at or above
278        // the count fails loudly here instead of replaying the wrong
279        // group silently. `None` means unknown (hand-built or legacy
280        // plan) and skips the check honestly. The `group_columns`
281        // mapping is traceability metadata only (EXPLAIN-visible): the
282        // schema alias namespace and the runtime column namespace are
283        // not identical, so names are logged, never validated.
284        if let UnaryOperatorKind::Flatten {
285            group_pos,
286            group_columns,
287            expected_groups,
288            ..
289        } = &self.kind
290        {
291            match expected_groups {
292                Some(count) if *group_pos >= *count => {
293                    return Err(QueryError::execution(format!(
294                        "Flatten(group={group_pos}): position out of range for {count} group(s)"
295                    )));
296                }
297                Some(count) => {
298                    log::debug!(
299                        "flatten(group={group_pos}): position within {count} group(s), mapped columns: [{}]",
300                        group_columns.join(", ")
301                    );
302                }
303                None => {
304                    log::debug!(
305                        "flatten(group={group_pos}): unknown group count; skipping position validation"
306                    );
307                }
308            }
309        }
310        input.open()?;
311        Ok(())
312    }
313
314    pub fn next(&mut self, input: &mut StreamingExecutor) -> Result<Option<DataChunk>, QueryError> {
315        if matches!(&self.kind, UnaryOperatorKind::Filter { .. }) {
316            return filter::handle(self, input);
317        }
318        if matches!(&self.kind, UnaryOperatorKind::Project { .. }) {
319            return project::handle(self, input);
320        }
321        if matches!(&self.kind, UnaryOperatorKind::Limit { .. }) {
322            return limit::handle(self, input);
323        }
324        if matches!(&self.kind, UnaryOperatorKind::Dedup { .. }) {
325            return dedup::handle(self, input);
326        }
327        if matches!(&self.kind, UnaryOperatorKind::Assign { .. }) {
328            return assign::handle(self, input);
329        }
330        if matches!(&self.kind, UnaryOperatorKind::Unwind { .. }) {
331            return unwind::handle(self, input);
332        }
333        if matches!(&self.kind, UnaryOperatorKind::AppendVertices { .. }) {
334            return append_vertices::handle(self, input);
335        }
336        if matches!(&self.kind, UnaryOperatorKind::Sample { .. }) {
337            return sample::handle(self, input);
338        }
339        if matches!(&self.kind, UnaryOperatorKind::Flatten { .. }) {
340            return flatten::handle(self, input);
341        }
342        unreachable!("unary_operator::next called for an unknown kind")
343    }
344
345    pub fn stop(&mut self) -> Result<(), QueryError> {
346        Ok(())
347    }
348
349    /// Reset this operator's per-run counters/buffers and rewind the input.
350    ///
351    /// Stateless operators (Filter/Project/Assign/AppendVertices)
352    /// are no-ops beyond rewinding the input; Limit/Sample reset their
353    /// counters; Dedup clears its seen-set; Unwind clears its input buffer.
354    pub fn reset(&mut self, input: &mut StreamingExecutor) -> Result<bool, QueryError> {
355        match &mut self.kind {
356            UnaryOperatorKind::Limit {
357                skipped, consumed, ..
358            } => {
359                *skipped = 0;
360                *consumed = 0;
361            }
362            UnaryOperatorKind::Dedup { seen_rows } => seen_rows.clear(),
363            UnaryOperatorKind::Unwind {
364                col_index,
365                layout,
366                all_rows,
367                input_multiplicity,
368                current_row_index,
369                current_unwind_index,
370                input_done,
371                ..
372            } => {
373                *col_index = None;
374                *layout = None;
375                all_rows.clear();
376                *input_multiplicity = 1;
377                *current_row_index = 0;
378                *current_unwind_index = 0;
379                *input_done = false;
380            }
381            UnaryOperatorKind::Sample { consumed, .. } => *consumed = 0,
382            UnaryOperatorKind::Flatten {
383                current_idx,
384                size_to_flatten,
385                saved_sel_vector,
386                buffered_chunk,
387                batch_size,
388                ..
389            } => {
390                *current_idx = 0;
391                *size_to_flatten = 0;
392                *saved_sel_vector = None;
393                *buffered_chunk = None;
394                *batch_size =
395                    crate::executor::streaming::operators::flatten::DEFAULT_FLATTEN_BATCH_SIZE;
396            }
397            UnaryOperatorKind::Filter { .. }
398            | UnaryOperatorKind::Project { .. }
399            | UnaryOperatorKind::Assign { .. }
400            | UnaryOperatorKind::AppendVertices { .. } => {}
401        }
402        input.reset()?;
403        Ok(false)
404    }
405
406    pub fn close(&mut self) -> Result<(), QueryError> {
407        if let UnaryOperatorKind::Flatten { buffered_chunk, .. } = &mut self.kind {
408            if let Some(chunk) = buffered_chunk.take() {
409                if let Some(stats) = chunk.columnar_stats {
410                    stats.record_selection_materialized();
411                }
412            }
413        }
414        Ok(())
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::executor::base::MemoryBudget;
422    use crate::executor::streaming::operators::base::OperatorBase;
423    use crate::executor::streaming::operators::source_operator::SourceOperator;
424    use crate::executor::streaming::operators::source_operator::SourceOperatorKind;
425    use crate::executor::streaming::runtime::{ExecutionRuntime, QueryIdentity};
426    use crate::storage::StorageWriter;
427    use linkrs_core::types::storage_ids::VertexId;
428    use linkrs_core::{Tag, Vertex};
429    use parking_lot::RwLock;
430
431    fn runtime_with_storage(
432        storage: Arc<RwLock<dyn crate::storage::QueryStorage>>,
433    ) -> Arc<ExecutionRuntime> {
434        Arc::new(ExecutionRuntime::new(
435            QueryIdentity::default(),
436            MemoryBudget::new(1024 * 1024),
437            Some(storage),
438            crate::executor::base::SearchContext::default(),
439        ))
440    }
441
442    fn scan_source(rows: Vec<Vec<Value>>, col_names: Vec<String>) -> Box<StreamingExecutor> {
443        Box::new(StreamingExecutor::Source(
444            OperatorBase::new(0),
445            SourceOperator::new(
446                SourceOperatorKind::ScanVertices {
447                    buffer: rows,
448                    current_index: 0,
449                    col_names,
450                },
451                Arc::new(SlotLayout::new(Vec::new())),
452            ),
453        ))
454    }
455
456    #[test]
457    fn append_vertices_fetches_vertex_and_appends_flat_columns() {
458        let mut mock = crate::storage::MockStorage::new().expect("MockStorage should be created");
459        mock.insert_vertex(
460            "test",
461            Vertex::new(
462                VertexId::try_from_int64(1).expect("valid vertex id"),
463                Tag::new(
464                    "person".to_string(),
465                    vec![
466                        ("name".into(), Value::string("Alice")),
467                        ("age".into(), Value::Int(30)),
468                    ]
469                    .into_iter()
470                    .collect(),
471                ),
472            ),
473        )
474        .expect("insert vertex");
475        let storage: Arc<RwLock<dyn crate::storage::QueryStorage>> = Arc::new(RwLock::new(mock));
476
477        // Input row: [vid]. The id is typed: a numeric string stays text
478        // under the typed VertexId contract, so the integer form is used
479        // for an integer vertex (the mock has no space vid_type to
480        // normalize through; real storage normalizes at assembly).
481        let input = scan_source(vec![vec![Value::Int(1)]], vec!["vid".to_string()]);
482        let mut append = StreamingExecutor::Unary(
483            OperatorBase::new(0),
484            input,
485            UnaryOperator::new(
486                UnaryOperatorKind::AppendVertices {
487                    entity_var: "v".to_string(),
488                    entity_expr: Expression::Variable("vid".to_string()),
489                    prop_names: vec![Arc::from("name"), Arc::from("age")],
490                    tag: "person".to_string(),
491                    storage: None,
492                    space_name: "test".to_string(),
493                    state: UnaryOperatorState::default(),
494                },
495                Arc::new(SlotLayout::new(Vec::new())),
496            ),
497        );
498        append.base_mut().runtime = Some(runtime_with_storage(storage.clone()));
499        append.open().expect("open should succeed");
500        let chunk = append.advance().expect("advance should succeed");
501        let chunk = chunk.expect("one output chunk");
502        assert_eq!(chunk.len(), 1);
503        let row = &chunk.rows[0];
504        assert_eq!(row.len(), 3, "vid + name + age");
505        assert_eq!(row[0], Value::Int(1));
506        assert_eq!(row[1], Value::string("Alice"));
507        assert_eq!(row[2], Value::Int(30));
508        assert!(append.advance().expect("advance").is_none());
509    }
510
511    #[test]
512    fn append_vertices_full_value_appends_vertex_object() {
513        let mut mock = crate::storage::MockStorage::new().expect("MockStorage should be created");
514        mock.insert_vertex(
515            "test",
516            Vertex::new(
517                VertexId::try_from_int64(7).expect("valid vertex id"),
518                Tag::new(
519                    "person".to_string(),
520                    vec![("name".into(), Value::string("Bob"))]
521                        .into_iter()
522                        .collect(),
523                ),
524            ),
525        )
526        .expect("insert vertex");
527        let storage: Arc<RwLock<dyn crate::storage::QueryStorage>> = Arc::new(RwLock::new(mock));
528
529        let input = scan_source(vec![vec![Value::Int(7)]], vec!["vid".to_string()]);
530        let mut append = StreamingExecutor::Unary(
531            OperatorBase::new(0),
532            input,
533            UnaryOperator::new(
534                UnaryOperatorKind::AppendVertices {
535                    entity_var: "v".to_string(),
536                    entity_expr: Expression::Variable("vid".to_string()),
537                    prop_names: vec![],
538                    tag: "person".to_string(),
539                    storage: None,
540                    space_name: "test".to_string(),
541                    state: UnaryOperatorState::default(),
542                },
543                Arc::new(SlotLayout::new(Vec::new())),
544            ),
545        );
546        append.base_mut().runtime = Some(runtime_with_storage(storage.clone()));
547        append.open().expect("open should succeed");
548        let chunk = append.advance().expect("advance").expect("one chunk");
549        let row = &chunk.rows[0];
550        assert_eq!(row.len(), 2, "vid + full vertex");
551        match &row[1] {
552            Value::Vertex(vertex) => {
553                assert_eq!(
554                    vertex.vid,
555                    VertexId::try_from_int64(7).expect("valid vertex id")
556                );
557                assert_eq!(vertex.property_value("name"), Some(Value::string("Bob")));
558            }
559            other => panic!("expected full vertex, got {:?}", other),
560        }
561    }
562
563    #[test]
564    fn append_vertices_missing_vertex_yields_null_columns() {
565        let storage: Arc<RwLock<dyn crate::storage::QueryStorage>> = Arc::new(RwLock::new(
566            crate::storage::MockStorage::new().expect("MockStorage should be created"),
567        ));
568        let input = scan_source(
569            vec![vec![Value::string("missing")]],
570            vec!["vid".to_string()],
571        );
572        let mut append = StreamingExecutor::Unary(
573            OperatorBase::new(0),
574            input,
575            UnaryOperator::new(
576                UnaryOperatorKind::AppendVertices {
577                    entity_var: "v".to_string(),
578                    entity_expr: Expression::Variable("vid".to_string()),
579                    prop_names: vec![Arc::from("name")],
580                    tag: "person".to_string(),
581                    storage: None,
582                    space_name: "test".to_string(),
583                    state: UnaryOperatorState::default(),
584                },
585                Arc::new(SlotLayout::new(Vec::new())),
586            ),
587        );
588        append.base_mut().runtime = Some(runtime_with_storage(storage.clone()));
589        append.open().expect("open should succeed");
590        let chunk = append.advance().expect("advance").expect("one chunk");
591        let row = &chunk.rows[0];
592        assert_eq!(row.len(), 2);
593        assert!(matches!(row[1], Value::Null(_)));
594    }
595
596    #[test]
597    fn flatten_group_pos_is_observable() {
598        let kind = UnaryOperatorKind::Flatten {
599            group_pos: 7,
600            group_columns: vec!["a".to_string()],
601            expected_groups: Some(8),
602            current_idx: 0,
603            size_to_flatten: 0,
604            saved_sel_vector: None,
605            buffered_chunk: None,
606            batch_size: crate::executor::streaming::operators::flatten::DEFAULT_FLATTEN_BATCH_SIZE,
607        };
608        assert_eq!(kind.flatten_group_pos(), Some(7));
609        let filter = UnaryOperatorKind::Limit {
610            offset: 0,
611            limit: 1,
612            skipped: 0,
613            consumed: 0,
614        };
615        assert_eq!(filter.flatten_group_pos(), None);
616    }
617
618    fn flatten_op_with_mapping(
619        group_columns: Vec<String>,
620        expected_groups: Option<u32>,
621        layout_names: &[&str],
622    ) -> (UnaryOperator, Box<StreamingExecutor>) {
623        let layout = Arc::new(SlotLayout::from_names(
624            &layout_names
625                .iter()
626                .map(|s| s.to_string())
627                .collect::<Vec<_>>(),
628        ));
629        let op = UnaryOperator::from_spec(
630            &crate::executor::streaming::operators::spec::UnarySpec::Flatten {
631                group_pos: 1,
632                group_columns,
633                expected_groups,
634            },
635            layout,
636        );
637        let input = scan_source(
638            vec![vec![Value::Int(1)]],
639            layout_names.iter().map(|s| s.to_string()).collect(),
640        );
641        (op, input)
642    }
643
644    #[test]
645    fn flatten_open_accepts_position_within_expected_groups() {
646        let (mut op, mut input) =
647            flatten_op_with_mapping(vec!["b".to_string()], Some(2), &["a", "b"]);
648        assert!(op.open(&mut input).is_ok());
649    }
650
651    #[test]
652    fn flatten_open_without_expected_groups_skips_validation() {
653        let (mut op, mut input) = flatten_op_with_mapping(vec![], None, &["a", "b"]);
654        assert!(op.open(&mut input).is_ok());
655    }
656
657    #[test]
658    fn flatten_open_rejects_stale_group_position() {
659        // Plan/executor drift: group 1 cannot exist when the rewrite saw
660        // only one group. The stale position fails loudly at open instead
661        // of replaying the wrong group silently.
662        let (mut op, mut input) =
663            flatten_op_with_mapping(vec!["b".to_string()], Some(1), &["a", "b"]);
664        let err = op
665            .open(&mut input)
666            .expect_err("stale group position must fail");
667        assert!(
668            err.to_string().contains("out of range"),
669            "error must report the range violation, got: {err}"
670        );
671    }
672
673    #[test]
674    fn append_vertices_without_storage_fails() {
675        let input = scan_source(vec![vec![Value::Int(1)]], vec!["vid".to_string()]);
676        let mut append = StreamingExecutor::Unary(
677            OperatorBase::new(0),
678            input,
679            UnaryOperator::new(
680                UnaryOperatorKind::AppendVertices {
681                    entity_var: "v".to_string(),
682                    entity_expr: Expression::Variable("vid".to_string()),
683                    prop_names: vec![Arc::from("name")],
684                    tag: "person".to_string(),
685                    storage: None,
686                    space_name: "test".to_string(),
687                    state: UnaryOperatorState::default(),
688                },
689                Arc::new(SlotLayout::new(Vec::new())),
690            ),
691        );
692        append.open().expect("open should succeed");
693        let error = append.advance().expect_err("storage required");
694        assert!(error.to_string().contains("requires storage"));
695    }
696}
697
698#[cfg(test)]
699mod project_fast_path_tests {
700    use super::project::is_passthrough_or_const;
701    use super::*;
702    use linkrs_core::Value;
703
704    fn layout() -> Arc<SlotLayout> {
705        Arc::new(SlotLayout::from_names(&[
706            "n".to_string(),
707            "n.age".to_string(),
708        ]))
709    }
710
711    fn chunk_with_selection() -> DataChunk {
712        let rows = vec![
713            vec![Value::Int(1), Value::Int(30)],
714            vec![
715                Value::Int(2),
716                Value::Null(linkrs_core::value::NullType::Null),
717            ],
718            vec![Value::Int(3), Value::Int(40)],
719        ];
720        DataChunk::new_with_layout(rows, layout())
721            .with_selection(vec![0, 2])
722            .with_multiplicity(3)
723    }
724
725    #[test]
726    fn test_is_passthrough_or_const() {
727        assert!(is_passthrough_or_const(&Expression::Variable(
728            "n".to_string()
729        )));
730        assert!(is_passthrough_or_const(&Expression::Literal(Value::Int(1))));
731        assert!(is_passthrough_or_const(&Expression::Property {
732            object: Box::new(Expression::Variable("n".to_string())),
733            property: "age".to_string(),
734        }));
735        assert!(!is_passthrough_or_const(&Expression::Binary {
736            left: Box::new(Expression::Variable("n".to_string())),
737            op: linkrs_core::types::operators::BinaryOperator::Add,
738            right: Box::new(Expression::Literal(Value::Int(1))),
739        }));
740    }
741
742    #[test]
743    fn test_project_fast_path_gathers_all_rows() {
744        // The fast path serves the no-selection branch of `Project::next`:
745        // every visible row is evaluated, including NULLs.
746        let exprs = vec![
747            Expression::Variable("n".to_string()),
748            Expression::Property {
749                object: Box::new(Expression::Variable("n".to_string())),
750                property: "age".to_string(),
751            },
752            Expression::Literal(Value::Int(7)),
753        ];
754        let mut state = UnaryOperatorState::default();
755        let rows = vec![
756            vec![Value::Int(1), Value::Int(30)],
757            vec![
758                Value::Int(2),
759                Value::Null(linkrs_core::value::NullType::Null),
760            ],
761            vec![Value::Int(3), Value::Int(40)],
762        ];
763        let mut chunk = DataChunk::new_with_layout(rows, layout());
764        let fast = UnaryOperator::evaluate_project_expressions(&mut chunk, &exprs, &mut state)
765            .expect("fast path evaluates");
766        assert_eq!(
767            fast,
768            vec![
769                vec![Value::Int(1), Value::Int(2), Value::Int(3)],
770                vec![
771                    Value::Int(30),
772                    Value::Null(linkrs_core::value::NullType::Null),
773                    Value::Int(40)
774                ],
775                vec![Value::Int(7), Value::Int(7), Value::Int(7)],
776            ]
777        );
778
779        // The selection branch keeps visible-row semantics (NULL row hidden
780        // here) and carries multiplicity into the projected chunk.
781        let mut selected = chunk_with_selection();
782        let mut visible = Vec::new();
783        for expr in &exprs {
784            visible.push(
785                selected
786                    .evaluate_expression_visible(expr, Some(&state.env))
787                    .expect("visible evaluates"),
788            );
789        }
790        let out_layout = Arc::new(SlotLayout::from_names(&[
791            "n".to_string(),
792            "n.age".to_string(),
793            "c".to_string(),
794        ]));
795        let out = DataChunk::project_columns(visible, Arc::clone(&out_layout)).with_multiplicity(3);
796        assert_eq!(out.rows.len(), 2);
797        assert_eq!(
798            out.rows[0],
799            vec![Value::Int(1), Value::Int(30), Value::Int(7)]
800        );
801        assert_eq!(
802            out.rows[1],
803            vec![Value::Int(3), Value::Int(40), Value::Int(7)]
804        );
805        assert_eq!(out.multiplicity(), 3);
806    }
807}