Skip to main content

linkrs_query/executor/streaming/plan/
materializer.rs

1//! PhysicalPlanMaterializer: converts an arena [`PhysicalPlan`] into a
2//! [`StreamingExecutor`] tree.
3//!
4//! This is the single instantiation path for all production queries:
5//!
6//! ```text
7//! Arc<PhysicalPlan> + QueryBindings
8//!   -> validate
9//!   -> allocate runtime + state arenas
10//!   -> build operator tree per fragment
11//!   -> return root StreamingExecutor
12//! ```
13
14use std::collections::HashMap;
15use std::sync::Arc;
16
17use super::super::executor::StreamingExecutor;
18use super::super::operators::apply_operator::ApplyOperator;
19use super::super::operators::apply_operator::ApplyOperatorKind;
20use super::super::operators::base::OperatorBase;
21use super::super::operators::blocking::BlockingOperator;
22use super::super::operators::ddl_operator::DdlOperator;
23use super::super::operators::exchange_operator::ExchangeOperator;
24use super::super::operators::fulltext_operator::FulltextOperator;
25use super::super::operators::graph_operator::GraphOperator;
26use super::super::operators::join_operator::JoinOperator;
27use super::super::operators::recursive_fragment_operator::RecursiveFragmentOperator;
28use super::super::operators::set_operator::SetOperator;
29use super::super::operators::sink_operator::SinkOperator;
30use super::super::operators::source_operator::SourceOperator;
31use super::super::operators::source_operator::SourceOperatorKind;
32use super::super::operators::spec::ApplySpec;
33use super::super::operators::txn_operator::TxnOperator;
34use super::super::operators::unary_operator::UnaryOperator;
35use super::super::operators::vector_operator::VectorOperator;
36use super::super::operators::wco_operator::WcoIntersectOperator;
37use super::super::runtime::ExecutionRuntime;
38use super::super::subquery::SubqueryExecutor;
39use super::types::{
40    FragmentGraph, FragmentId, FragmentSpec, InputContract, OperatorKindSpec, PhysicalPlan,
41};
42use crate::executor::base::MemoryTracker;
43use linkrs_core::error::QueryError;
44
45use super::super::instance::QueryBindings;
46
47/// Converts an arena [`PhysicalPlan`] into a [`StreamingExecutor`] tree.
48///
49/// Processing order:
50/// 1. Validate bindings and capabilities.
51/// 2. Create [`ExecutionRuntime`] from bindings.
52/// 3. Walk fragment graph in topological order (producers before consumers).
53/// 4. For each fragment, build the operator pipeline from its arena specs.
54/// 5. Connect fragment inputs by moving them from the producer map.
55/// 6. Return root executor.
56pub struct PhysicalPlanMaterializer;
57
58impl PhysicalPlanMaterializer {
59    /// Materialize a physical plan into an executable operator tree.
60    ///
61    /// validates bindings and builds the [`ParameterFrame`] for
62    /// slot-based parameter access during execution.
63    pub fn materialize(
64        plan: &PhysicalPlan,
65        bindings: &QueryBindings,
66    ) -> Result<(StreamingExecutor, Arc<ExecutionRuntime>), QueryError> {
67        Self::validate_bindings(plan, bindings)?;
68
69        // build the parameter frame — this requires a mutable bindings
70        // clone to set the frame.  We clone because the caller's bindings
71        // are immutable past this point.
72        let mut mutable_bindings = bindings.clone();
73        if !plan.parameter_schema.is_empty() {
74            mutable_bindings.build_parameter_frame(&plan.parameter_schema);
75        }
76
77        let parameter_values = if let Some(ref frame) = mutable_bindings.parameter_frame {
78            let map: std::collections::HashMap<String, linkrs_core::Value> = plan
79                .parameter_schema
80                .params
81                .iter()
82                .filter_map(|p| frame.get(p.slot).map(|v| (p.name.clone(), v.clone())))
83                .collect();
84            if map.is_empty() {
85                None
86            } else {
87                Some(std::sync::Arc::new(map))
88            }
89        } else {
90            None
91        };
92
93        let runtime = Self::create_runtime(&mutable_bindings, parameter_values);
94
95        let topo_order = Self::topological_order(&plan.fragments)?;
96
97        // fragment id → root executor (owned, moved out when consumed).
98        let mut fragment_roots: HashMap<FragmentId, StreamingExecutor> = HashMap::new();
99
100        for &fid in &topo_order {
101            let fragment = plan
102                .fragments
103                .get(fid)
104                .ok_or_else(|| QueryError::execution(format!("Fragment {:?} not found", fid)))?;
105
106            let executor = Self::build_fragment_pipeline(
107                fragment,
108                plan,
109                &mut fragment_roots,
110                &runtime,
111                &mutable_bindings,
112            )?;
113            fragment_roots.insert(fid, executor);
114        }
115
116        let root_executor = fragment_roots.remove(&plan.root_fragment).ok_or_else(|| {
117            QueryError::execution(format!(
118                "Root fragment {:?} has no materialized executor",
119                plan.root_fragment
120            ))
121        })?;
122
123        Ok((root_executor, runtime))
124    }
125
126    // ── Fragment pipeline construction ──
127
128    /// Build the operator tree for a single fragment.
129    ///
130    /// Within a fragment, operators form a strict leaf-to-root pipeline. The
131    /// first operator consumes the fragment's external input contract; every
132    /// later operator consumes only the preceding operator.
133    fn build_fragment_pipeline(
134        fragment: &FragmentSpec,
135        plan: &PhysicalPlan,
136        fragment_roots: &mut HashMap<FragmentId, StreamingExecutor>,
137        runtime: &Arc<ExecutionRuntime>,
138        bindings: &QueryBindings,
139    ) -> Result<StreamingExecutor, QueryError> {
140        let mut previous = None;
141
142        for (operator_index, &op_id) in fragment.operators.iter().enumerate() {
143            let op_spec = plan.operator(op_id).ok_or_else(|| {
144                QueryError::execution(format!("Operator {:?} not found in plan arena", op_id))
145            })?;
146
147            let plan_node_id = op_spec
148                .logical_node_id
149                .map(|logical_id| logical_id.0)
150                .unwrap_or(op_spec.operator_id.0 as i64);
151            let output_layout = Arc::new(op_spec.output_layout.clone());
152            let base = OperatorBase::new(plan_node_id)
153                .with_physical_operator_id(op_spec.operator_id)
154                .with_output_layout(output_layout.clone());
155
156            let mut inputs = if operator_index == 0 {
157                take_external_inputs(&op_spec.input_contract, fragment_roots)?
158            } else {
159                vec![previous.take().ok_or_else(|| {
160                    QueryError::execution(format!(
161                        "Fragment {:?} lost its linear pipeline before operator {:?}",
162                        fragment.id, op_id
163                    ))
164                })?]
165            };
166
167            let exec = match &op_spec.spec {
168                OperatorKindSpec::Source(src_spec) => {
169                    require_input_count(fragment.id, op_id, &inputs, 0)?;
170                    let storage = bindings.storage.clone();
171                    let op = SourceOperator::from_spec(src_spec, storage, output_layout.clone());
172                    StreamingExecutor::Source(base, op)
173                }
174                OperatorKindSpec::Unary(unary_spec) => {
175                    let child = take_unary_input(fragment.id, op_id, &mut inputs)?;
176                    let mut op = UnaryOperator::from_spec(unary_spec, output_layout.clone());
177                    // Instantiate a per-operator SubqueryExecutor
178                    // from the spec's compiled runner specs (fresh mutable
179                    // state per operator instance / partition).
180                    let runner_specs = unary_spec.subquery_runners();
181                    if !runner_specs.is_empty() {
182                        let executor = Arc::new(SubqueryExecutor::from_specs(
183                            runtime.clone(),
184                            Arc::new(stripped_bindings(bindings)),
185                            runner_specs,
186                        ));
187                        op.set_subquery_executor(executor);
188                    }
189                    StreamingExecutor::Unary(base, Box::new(child), op)
190                }
191                OperatorKindSpec::Blocking(blocking_spec) => {
192                    let child = take_unary_input(fragment.id, op_id, &mut inputs)?;
193                    let op = BlockingOperator::from_spec(
194                        blocking_spec,
195                        &bindings.memory_budget,
196                        output_layout.clone(),
197                    );
198                    StreamingExecutor::Blocking(base, Box::new(child), op)
199                }
200                OperatorKindSpec::Join(join_spec) => {
201                    let (left, right) = take_binary_inputs(fragment.id, op_id, inputs)?;
202                    let op = JoinOperator::from_spec(
203                        join_spec,
204                        &bindings.memory_budget,
205                        output_layout.clone(),
206                    );
207                    StreamingExecutor::Join(base, Box::new(left), Box::new(right), Box::new(op))
208                }
209                OperatorKindSpec::Wco(wco_spec) => {
210                    require_input_count(fragment.id, op_id, &inputs, 1 + wco_spec.num_builds())?;
211                    let mut inputs = inputs.into_iter();
212                    let probe = inputs.next().ok_or_else(|| {
213                        QueryError::execution(format!(
214                            "Wco operator {:?} in fragment {:?} has no probe input",
215                            op_id, fragment.id
216                        ))
217                    })?;
218                    let builds: Vec<StreamingExecutor> = inputs.collect();
219                    let op = WcoIntersectOperator::from_spec(
220                        wco_spec,
221                        &bindings.memory_budget,
222                        output_layout.clone(),
223                    );
224                    StreamingExecutor::Wco(base, Box::new(probe), builds, op)
225                }
226                OperatorKindSpec::Graph(graph_spec) => {
227                    let child = take_unary_input(fragment.id, op_id, &mut inputs)?;
228                    let storage = runtime.storage.clone();
229                    let space_name = runtime.query_id().space_name.clone().unwrap_or_default();
230                    let op = GraphOperator::from_spec(
231                        graph_spec,
232                        storage,
233                        space_name,
234                        output_layout.clone(),
235                    );
236                    StreamingExecutor::Graph(base, Box::new(child), op)
237                }
238                OperatorKindSpec::RecursiveFragment(rf_spec) => {
239                    let child = take_unary_input(fragment.id, op_id, &mut inputs)?;
240                    let storage = runtime.storage.clone();
241                    let space_name = runtime.query_id().space_name.clone().unwrap_or_default();
242                    let op = RecursiveFragmentOperator::from_spec(
243                        rf_spec,
244                        storage,
245                        space_name,
246                        output_layout.clone(),
247                    );
248                    StreamingExecutor::RecursiveFragment(base, Box::new(child), op)
249                }
250                OperatorKindSpec::Sink(sink_spec) => {
251                    let child = take_unary_input(fragment.id, op_id, &mut inputs)?;
252                    let storage = runtime.storage.clone();
253                    let op = SinkOperator::from_spec(sink_spec, storage, output_layout.clone());
254                    StreamingExecutor::Sink(base, Box::new(child), op)
255                }
256                OperatorKindSpec::Set(set_spec) => {
257                    let (left, right) = take_binary_inputs(fragment.id, op_id, inputs)?;
258                    let op = SetOperator::from_spec(
259                        set_spec,
260                        &bindings.memory_budget,
261                        output_layout.clone(),
262                    );
263                    StreamingExecutor::Set(base, Box::new(left), Box::new(right), op)
264                }
265                OperatorKindSpec::Apply(apply_spec) => match apply_spec {
266                    ApplySpec::CorrelatedApply { sub_plan, anti } => {
267                        let left = take_unary_input(fragment.id, op_id, &mut inputs)?;
268                        let right = StreamingExecutor::Source(
269                            OperatorBase::new(0),
270                            SourceOperator::new(SourceOperatorKind::Start, output_layout.clone()),
271                        );
272                        let op = ApplyOperator::new(
273                            ApplyOperatorKind::CorrelatedApply {
274                                sub_plan: sub_plan.clone(),
275                                bindings: Box::new(stripped_bindings(bindings)),
276                                sub_executor: None,
277                                anti: *anti,
278                                right_rows: None,
279                                right_layout: None,
280                                memory_tracker: MemoryTracker::new(bindings.memory_budget.clone()),
281                            },
282                            output_layout.clone(),
283                        );
284                        StreamingExecutor::Apply(base, Box::new(left), Box::new(right), op)
285                    }
286                    _ => {
287                        let (left, right) = take_binary_inputs(fragment.id, op_id, inputs)?;
288                        let op = ApplyOperator::from_spec(
289                            apply_spec,
290                            &bindings.memory_budget,
291                            output_layout.clone(),
292                        );
293                        StreamingExecutor::Apply(base, Box::new(left), Box::new(right), op)
294                    }
295                },
296                OperatorKindSpec::Exchange(exchange_spec) => {
297                    if inputs.is_empty() {
298                        return Err(QueryError::execution(format!(
299                            "Exchange operator {:?} in fragment {:?} has no inputs",
300                            op_id, fragment.id
301                        )));
302                    }
303                    let children = inputs;
304                    let op = ExchangeOperator::from_spec(exchange_spec, output_layout.clone());
305                    StreamingExecutor::Exchange(base, children, op)
306                }
307                OperatorKindSpec::Ddl(ddl_spec) => {
308                    let child = take_unary_input(fragment.id, op_id, &mut inputs)?;
309                    let storage = runtime.storage.clone();
310                    let op = DdlOperator::from_spec(ddl_spec, storage, output_layout.clone());
311                    StreamingExecutor::Ddl(base, Box::new(child), op)
312                }
313                OperatorKindSpec::Fulltext(ft_spec) => {
314                    let child = take_unary_input(fragment.id, op_id, &mut inputs)?;
315                    let storage = runtime.storage.clone();
316                    #[cfg(feature = "fulltext")]
317                    let ft_mgr = runtime.search.fulltext_manager.clone();
318                    let op = FulltextOperator::from_spec(
319                        ft_spec,
320                        storage,
321                        #[cfg(feature = "fulltext")]
322                        ft_mgr,
323                        output_layout.clone(),
324                    );
325                    StreamingExecutor::Fulltext(base, Box::new(child), op)
326                }
327                OperatorKindSpec::Vector(vector_spec) => {
328                    let child = take_unary_input(fragment.id, op_id, &mut inputs)?;
329                    let storage = runtime.storage.clone();
330                    #[cfg(feature = "vector")]
331                    let coord = runtime.search.vector_coordinator.clone();
332                    let op = VectorOperator::from_spec(
333                        vector_spec,
334                        storage,
335                        #[cfg(feature = "vector")]
336                        coord,
337                        output_layout.clone(),
338                    );
339                    StreamingExecutor::Vector(base, Box::new(child), op)
340                }
341                OperatorKindSpec::Txn(txn_spec) => {
342                    let child = take_unary_input(fragment.id, op_id, &mut inputs)?;
343                    let op = TxnOperator::from_spec(txn_spec, output_layout.clone());
344                    StreamingExecutor::Txn(base, Box::new(child), op)
345                }
346            };
347
348            previous = Some(exec);
349        }
350
351        let mut root_executor = previous.ok_or_else(|| {
352            QueryError::execution(format!("Fragment {:?} produced no operators", fragment.id))
353        })?;
354
355        root_executor.set_chunk_size(bindings.chunk_size);
356        root_executor.set_runtime(Some(runtime.clone()));
357
358        Ok(root_executor)
359    }
360
361    // ── Fragment graph traversal ──
362
363    fn topological_order(fragments: &FragmentGraph) -> Result<Vec<FragmentId>, QueryError> {
364        let root = fragments.root();
365        let mut order = Vec::new();
366        let mut visited = std::collections::HashSet::new();
367        let mut stack = vec![root];
368
369        while let Some(fid) = stack.pop() {
370            if !visited.insert(fid) {
371                continue;
372            }
373            if let Some(frag) = fragments.get(fid) {
374                for &input in &frag.inputs {
375                    stack.push(input);
376                }
377                order.push(fid);
378            }
379        }
380
381        order.reverse();
382
383        if order.is_empty() {
384            return Err(QueryError::execution("Fragment graph is empty".to_string()));
385        }
386
387        Ok(order)
388    }
389
390    // ── Binding validation ──
391
392    /// Validate bindings against the plan's parameter schema.
393    ///
394    /// checks for missing required params, unknown params, and type
395    /// compatibility.  Returns an error description listing all violations.
396    fn validate_bindings(plan: &PhysicalPlan, bindings: &QueryBindings) -> Result<(), QueryError> {
397        let schema = &plan.parameter_schema;
398        let mut errors: Vec<String> = Vec::new();
399
400        // Check missing required params.
401        for param in &schema.params {
402            if !bindings.parameters.contains_key(&param.name) && param.default.is_none() {
403                errors.push(format!("Missing required parameter: {}", param.name));
404            }
405        }
406
407        // Check unknown params (present in bindings but not in schema).
408        for name in bindings.parameters.keys() {
409            if schema.slot(name).is_none() {
410                errors.push(format!("Unknown parameter: {}", name));
411            }
412        }
413
414        // Check type compatibility for params present in both.
415        for param in &schema.params {
416            if let Some(value) = bindings.parameters.get(&param.name) {
417                if let Some(ref expected_type) = param.value_type {
418                    if !Self::type_compatible(value, expected_type) {
419                        errors.push(format!(
420                            "Parameter '{}': expected type {:?}, got value {:?}",
421                            param.name, expected_type, value
422                        ));
423                    }
424                }
425            }
426        }
427
428        if errors.is_empty() {
429            Ok(())
430        } else {
431            Err(QueryError::execution(errors.join("; ")))
432        }
433    }
434
435    /// Rough type compatibility check for parameter values.
436    /// ensures the runtime value is semantically assignable to the
437    /// declared parameter type.
438    fn type_compatible(value: &linkrs_core::Value, expected_type: &linkrs_core::DataType) -> bool {
439        use linkrs_core::DataType;
440        use linkrs_core::Value;
441        matches!(
442            (value, expected_type),
443            (Value::Null(_), _)
444                | (Value::Bool(_), DataType::Bool)
445                | (Value::Int(_), DataType::Int | DataType::BigInt)
446                | (Value::BigInt(_), DataType::BigInt | DataType::Int)
447                | (Value::Float(_), DataType::Float | DataType::Double)
448                | (Value::Double(_), DataType::Double | DataType::Float)
449                | (Value::String(_), DataType::String)
450                | (Value::Date(_), DataType::Date)
451                | (Value::Time(_), DataType::Time)
452                | (Value::DateTime(_), DataType::DateTime)
453        ) || Self::type_compatible_composite(value, expected_type)
454    }
455
456    /// Composite (Struct/Array) parameter type compatibility: recursive field
457    /// and element checks plus the declared fixed length for ARRAY(n).
458    fn type_compatible_composite(
459        value: &linkrs_core::Value,
460        expected_type: &linkrs_core::DataType,
461    ) -> bool {
462        use linkrs_core::DataType;
463        use linkrs_core::Value;
464        match (value, expected_type) {
465            (Value::Struct(s), DataType::Struct(expected)) => {
466                // Every field of the value must exist in the declared type
467                // with a compatible type.
468                s.fields.iter().all(|(name, field_value)| {
469                    expected
470                        .fields
471                        .iter()
472                        .find(|(field_name, _)| field_name == name)
473                        .is_some_and(|(_, field_type)| {
474                            Self::type_compatible(field_value, field_type)
475                        })
476                })
477            }
478            (Value::Array(a), DataType::Array(expected)) => {
479                let length_ok = expected
480                    .len
481                    .is_none_or(|declared_len| a.values.len() == declared_len);
482                length_ok
483                    && a.values
484                        .iter()
485                        .all(|element| Self::type_compatible(element, &expected.element))
486            }
487            _ => false,
488        }
489    }
490
491    // ── Runtime creation ──
492
493    /// Create an [`ExecutionRuntime`] from bindings.
494    ///
495    /// injects transaction scope and session controller into the runtime
496    /// so that operators can check write permissions and transaction commands
497    /// can drive real state transitions.
498    fn create_runtime(
499        bindings: &QueryBindings,
500        parameter_values: Option<
501            std::sync::Arc<std::collections::HashMap<String, linkrs_core::Value>>,
502        >,
503    ) -> Arc<ExecutionRuntime> {
504        let mut runtime = ExecutionRuntime::new(
505            crate::executor::streaming::runtime::QueryIdentity {
506                query_id: bindings.query_id,
507                session_id: None,
508                space_name: bindings.space_name.clone(),
509            },
510            bindings.memory_budget.clone(),
511            bindings.storage.clone(),
512            bindings.search.clone(),
513        );
514
515        // inject transaction scope.
516        match bindings.transaction {
517            crate::executor::streaming::transaction_scope::TransactionScope::None => {
518                // DDL / admin commands may run without a txn scope, but DML will
519                // be rejected at the operator level (see SinkOperator::check_write_permission).
520            }
521            ref scope => {
522                runtime.set_transaction_scope(scope.clone());
523            }
524        }
525
526        // inject the parameter name→value map so operators can resolve $name.
527        if let Some(values) = parameter_values {
528            runtime.set_parameter_values(values);
529        }
530
531        // Session variable snapshot: operators resolve $name session variables
532        // through this map (captured once per statement at the API layer).
533        if !bindings.session_variables.is_empty() {
534            runtime.set_session_variable_values(bindings.session_variables.clone());
535        }
536
537        // Catalog managers: DDL operators resolve CREATE/DROP MACRO/TYPE and
538        // SHOW MACROS through these shared engine-wide registries.
539        if bindings.macro_manager.is_some() {
540            runtime.set_macro_manager(bindings.macro_manager.clone());
541        }
542        if bindings.type_alias_manager.is_some() {
543            runtime.set_type_alias_manager(bindings.type_alias_manager.clone());
544        }
545        if bindings.sequence_manager.is_some() {
546            runtime.set_sequence_manager(bindings.sequence_manager.clone());
547        }
548
549        // shared scheduler takes priority.
550        if let Some(ref ss) = bindings.shared_scheduler {
551            runtime.set_shared_scheduler(Some(ss.clone()));
552        } else if bindings.max_workers > 1 {
553            let pool =
554                crate::executor::streaming::pool::MorselWorkerPool::new(bindings.max_workers);
555            runtime.set_worker_pool(Some(pool));
556        }
557
558        // Stats feedback loop: inject the shared query feedback
559        // history so the execution instance can record estimated-vs-actual
560        // operator feedback after execution completes.
561        runtime.feedback_history = bindings.feedback_history.clone();
562
563        // Columnar auto-detection: inject the shared policy so the
564        // scan gates can adapt the typed columnar layout to learned hit rate,
565        // and the per-query stats are merged back at query completion.
566        runtime.set_columnar_policy(bindings.columnar_policy.clone());
567
568        // Size state_arenas for partitioned execution so each
569        // partition gets its own arena, eliminating contention on arena 0.
570        if bindings.partition_count > 0 {
571            runtime.set_partition_count(bindings.partition_count + 1);
572        }
573
574        // Wire bumpalo arena into the runtime for temporary allocations.
575        runtime.arena = bindings.arena.clone();
576
577        Arc::new(runtime)
578    }
579}
580
581/// Clone the bindings with the parameter maps cleared.
582///
583/// CorrelatedApply caches these bindings and re-materializes the nested right
584/// subtree per outer row.  The nested sub-plan is built with an empty
585/// parameter schema, so carrying the outer `parameters`/`parameter_frame`
586/// would make `validate_bindings` report `Unknown parameter`.  Runtime
587/// parameters are still resolved from the parent runtime's `parameter_values`,
588/// which the sub-executor inherits via `set_runtime(Some(parent))`.
589fn stripped_bindings(bindings: &QueryBindings) -> QueryBindings {
590    let mut stripped = bindings.clone();
591    stripped.parameters = Arc::new(HashMap::new());
592    stripped.parameter_frame = None;
593    stripped
594}
595
596fn take_external_inputs(
597    contract: &InputContract,
598    fragment_roots: &mut HashMap<FragmentId, StreamingExecutor>,
599) -> Result<Vec<StreamingExecutor>, QueryError> {
600    let ids: Vec<FragmentId> = match contract {
601        InputContract::NoInput => Vec::new(),
602        InputContract::UnaryInput(input) => vec![input.fragment],
603        InputContract::BinaryInputs { left, right } => vec![left.fragment, right.fragment],
604        InputContract::PartitionedInputs { members, .. } => {
605            let mut ordered = members.iter().collect::<Vec<_>>();
606            ordered.sort_by_key(|member| member.partition_id);
607            ordered.into_iter().map(|member| member.fragment).collect()
608        }
609        InputContract::WcoInputs { probe, builds } => {
610            let mut ids = Vec::with_capacity(builds.len() + 1);
611            ids.push(probe.fragment);
612            ids.extend(builds.iter().map(|build| build.fragment));
613            ids
614        }
615    };
616
617    ids.into_iter()
618        .map(|fragment_id| {
619            fragment_roots.remove(&fragment_id).ok_or_else(|| {
620                QueryError::execution(format!(
621                    "Input contract references unavailable producer fragment {:?}",
622                    fragment_id
623                ))
624            })
625        })
626        .collect()
627}
628
629fn require_input_count(
630    fragment_id: FragmentId,
631    operator_id: super::types::PhysicalOperatorId,
632    inputs: &[StreamingExecutor],
633    expected: usize,
634) -> Result<(), QueryError> {
635    if inputs.len() == expected {
636        Ok(())
637    } else {
638        Err(QueryError::execution(format!(
639            "Operator {:?} in fragment {:?} received {} inputs, expected {}",
640            operator_id,
641            fragment_id,
642            inputs.len(),
643            expected
644        )))
645    }
646}
647
648fn take_unary_input(
649    fragment_id: FragmentId,
650    operator_id: super::types::PhysicalOperatorId,
651    inputs: &mut Vec<StreamingExecutor>,
652) -> Result<StreamingExecutor, QueryError> {
653    require_input_count(fragment_id, operator_id, inputs, 1)?;
654    inputs.pop().ok_or_else(|| {
655        QueryError::execution(format!("Operator {:?} has no unary input", operator_id))
656    })
657}
658
659fn take_binary_inputs(
660    fragment_id: FragmentId,
661    operator_id: super::types::PhysicalOperatorId,
662    mut inputs: Vec<StreamingExecutor>,
663) -> Result<(StreamingExecutor, StreamingExecutor), QueryError> {
664    require_input_count(fragment_id, operator_id, &inputs, 2)?;
665    let right = inputs.pop().ok_or_else(|| {
666        QueryError::execution(format!("Operator {:?} has no right input", operator_id))
667    })?;
668    let left = inputs.pop().ok_or_else(|| {
669        QueryError::execution(format!("Operator {:?} has no left input", operator_id))
670    })?;
671    Ok((left, right))
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677    use crate::executor::base::ExecutionContext;
678    use linkrs_core::types::expr::expression_context::ExpressionAnalysisContext;
679    use linkrs_core::types::{ContextualExpression, ExpressionMeta};
680
681    use crate::executor::streaming::parameters::ParameterSchema;
682    use crate::executor::streaming::plan::arena_builder::PhysicalPlanBuilder;
683    use crate::executor::streaming::plan::context::PhysicalPlanBuildContext;
684    use crate::executor::streaming::transaction_scope::TransactionScope;
685    use crate::planning::plan::core::nodes::base::plan_node_enum::PlanNodeEnum;
686    use crate::planning::plan::core::nodes::base::plan_node_traits::PlanNode;
687    use crate::planning::plan::core::nodes::control_flow::start_node::StartNode;
688    use crate::planning::plan::core::nodes::control_flow::ArgumentNode;
689    use crate::planning::plan::core::nodes::graph_operations::graph_operations_node::CorrelatedApplyNode;
690    use crate::planning::plan::core::nodes::join::CrossJoinNode;
691    use crate::planning::plan::core::nodes::operation::filter_node::FilterNode;
692    use std::collections::HashMap;
693
694    fn build_correlated_apply_plan() -> Arc<PhysicalPlan> {
695        let left = PlanNodeEnum::Start(StartNode::new());
696
697        let mut argument = ArgumentNode::new(-2, "_correlated_apply");
698        argument.set_col_names(vec!["t".to_string()]);
699        let cross = CrossJoinNode::new(PlanNodeEnum::Argument(argument), left.clone())
700            .expect("cross join should build");
701        let expr_ctx = Arc::new(ExpressionAnalysisContext::new());
702        let expr_id = expr_ctx.register_expression(ExpressionMeta::new(
703            linkrs_core::Expression::Literal(linkrs_core::Value::Bool(true)),
704        ));
705        let condition = ContextualExpression::new(expr_id, expr_ctx);
706        let filter = FilterNode::new(cross.into_enum(), condition).expect("filter should build");
707        let right = PlanNodeEnum::Filter(filter);
708
709        let apply = CorrelatedApplyNode::new(left, right, false).expect("apply should build");
710        let node = PlanNodeEnum::CorrelatedApply(apply);
711
712        let mut ctx = PhysicalPlanBuildContext::new();
713        let exec_ctx = ExecutionContext::new(Arc::new(ExpressionAnalysisContext::new()));
714        let plan =
715            PhysicalPlanBuilder::build(&node, &mut ctx, &exec_ctx).expect("plan should build");
716        let plan = Arc::new(plan);
717        crate::executor::streaming::plan::validator::PhysicalPlanValidator::validate(&plan)
718            .expect("plan should validate");
719        plan
720    }
721
722    fn bindings() -> QueryBindings {
723        QueryBindings {
724            parameters: Arc::new(HashMap::new()),
725            session_variables: Arc::new(HashMap::new()),
726            parameter_frame: None,
727            space_name: None,
728            storage: None,
729            bound_snapshot: None,
730            memory_budget: crate::executor::base::MemoryBudget::default_budget(),
731            max_workers: 1,
732            chunk_size: 2048,
733            max_buffered_chunks: 4,
734            query_id: 1,
735            cancel_token: None,
736            session_id: None,
737            user_name: None,
738            query_text: None,
739            transaction: TransactionScope::None,
740            shared_scheduler: None,
741            partition_count: 0,
742            arena: None,
743            feedback_history: None,
744            columnar_policy: None,
745            macro_manager: None,
746            type_alias_manager: None,
747            sequence_manager: None,
748            search: crate::executor::base::SearchContext::default(),
749        }
750    }
751
752    #[test]
753    fn materialize_correlated_apply_uses_unary_input_and_start_placeholder() {
754        let plan = build_correlated_apply_plan();
755        let (root, _runtime) =
756            PhysicalPlanMaterializer::materialize(&plan, &bindings()).expect("should materialize");
757
758        let (left, right, op) = match root {
759            StreamingExecutor::Apply(_, left, right, op) => (left, right, op),
760            other => panic!("expected Apply executor, got {:?}", other.operator_name()),
761        };
762        assert!(
763            matches!(op.kind, ApplyOperatorKind::CorrelatedApply { .. }),
764            "CorrelatedApply spec must materialize into the CorrelatedApply operator"
765        );
766        assert!(
767            matches!(&*left, StreamingExecutor::Source(_, op) if matches!(op.kind, SourceOperatorKind::Start)),
768            "left input comes from the unary input fragment (Start source)"
769        );
770        assert!(
771            matches!(&*right, StreamingExecutor::Source(_, op) if matches!(op.kind, SourceOperatorKind::Start)),
772            "right input is the Start placeholder (never executed)"
773        );
774    }
775
776    #[test]
777    fn stripped_bindings_clears_parameters_for_nested_materialize() {
778        let plan = build_correlated_apply_plan();
779        let apply_spec = plan
780            .operators
781            .iter()
782            .find_map(|op| match &op.spec {
783                OperatorKindSpec::Apply(
784                    super::super::super::operators::spec::ApplySpec::CorrelatedApply {
785                        sub_plan,
786                        ..
787                    },
788                ) => Some(sub_plan.clone()),
789                _ => None,
790            })
791            .expect("plan contains CorrelatedApply");
792
793        // The sub-plan carries an empty parameter schema.
794        assert!(
795            apply_spec.parameter_schema.is_empty(),
796            "nested sub-plan must have an empty parameter schema"
797        );
798
799        // Unstripped bindings with a stray parameter would trip
800        // `validate_bindings` with "Unknown parameter" on the nested plan.
801        let schema = ParameterSchema::new(vec![
802            crate::executor::streaming::parameters::ParameterDesc {
803                name: "p".to_string(),
804                slot: crate::executor::streaming::parameters::ParameterSlot(0),
805                value_type: Some(linkrs_core::DataType::String),
806                nullable: true,
807                default: None,
808            },
809        ]);
810        let mut raw = bindings();
811        raw.parameters = Arc::new(
812            [("p".into(), linkrs_core::Value::string("value"))]
813                .into_iter()
814                .collect(),
815        );
816        raw.build_parameter_frame(&schema);
817        assert!(!raw.parameters.is_empty());
818        assert!(raw.parameter_frame.is_some());
819
820        let stripped = stripped_bindings(&raw);
821        assert!(
822            stripped.parameters.is_empty(),
823            "stripped bindings must drop the parameter map"
824        );
825        assert!(
826            stripped.parameter_frame.is_none(),
827            "stripped bindings must drop the parameter frame"
828        );
829
830        // The stripped bindings must materialize the nested sub-plan cleanly.
831        let (sub_root, _runtime) = PhysicalPlanMaterializer::materialize(&apply_spec, &stripped)
832            .expect("nested sub-plan should materialize without Unknown parameter");
833        assert!(sub_root.operator_name() == "Filter" || sub_root.operator_name() == "CrossJoin");
834    }
835}