nodedb_physical/physical_plan/query.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! Query operations (joins, aggregates) dispatched to the Data Plane.
4
5pub use nodedb_query::expr::GroupKeySpec;
6use nodedb_types::QualifiedCollection;
7
8/// Aggregate specification for Data Plane aggregate execution.
9#[derive(
10 Debug,
11 Clone,
12 PartialEq,
13 serde::Serialize,
14 serde::Deserialize,
15 zerompk::ToMessagePack,
16 zerompk::FromMessagePack,
17)]
18pub struct AggregateSpec {
19 pub function: String,
20 /// Internal aggregate key used by HAVING and downstream references.
21 pub alias: String,
22 /// Optional user-facing SQL alias for final output naming.
23 pub user_alias: Option<String>,
24 /// Field name for simple field-based aggregates. `"*"` is used for COUNT(*).
25 pub field: String,
26 /// Optional expression to evaluate per-document before aggregating.
27 pub expr: Option<nodedb_query::expr::SqlExpr>,
28 /// The constant parameter of a parameterized aggregate (see
29 /// [`super::PARAMETERIZED_AGGREGATE_FUNCTIONS`]); `None` for every other
30 /// function.
31 #[serde(default)]
32 pub param: Option<super::AggregateParam>,
33}
34
35#[derive(
36 Debug,
37 Clone,
38 PartialEq,
39 serde::Serialize,
40 serde::Deserialize,
41 zerompk::ToMessagePack,
42 zerompk::FromMessagePack,
43)]
44pub struct JoinProjection {
45 pub source: String,
46 pub output: String,
47}
48
49/// Query-level physical operations (joins, aggregates).
50#[derive(
51 Debug,
52 Clone,
53 PartialEq,
54 serde::Serialize,
55 serde::Deserialize,
56 zerompk::ToMessagePack,
57 zerompk::FromMessagePack,
58)]
59pub enum QueryOp {
60 /// Coordinator-resolved data-movement wrapper. Defensive error if it reaches a core.
61 Exchange(crate::physical_plan::ExchangeOp),
62
63 /// Pre-materialized rows. `provider: Some(name)` => a named relation (an
64 /// identity-scoped catalog table or a continuous aggregate) whose rows
65 /// the coordinator fills per request; `None` => `rows` is final
66 /// (constant SELECT or a gathered child). `rows` is a canonical msgpack
67 /// array of map values (the `encode_binary_rows` shape).
68 ///
69 /// The executor applies predicate, offset, sort, distinct, projection, and
70 /// limit in that order over the rows before emitting the response. Empty
71 /// `filters`/`projection` and `None` limit are no-ops (all rows emitted
72 /// unchanged through those stages).
73 ProviderScan {
74 /// Named relation the coordinator materializes. The name stays after
75 /// the coordinator fills `rows`, so the authorization of the filled
76 /// plan demands Read on it. The Data Plane reads only `rows`.
77 provider: Option<String>,
78 /// Msgpack array of plain map rows. Filled by the coordinator from the
79 /// catalog producer or from a gathered child.
80 rows: Vec<u8>,
81 /// What a gathered child read to produce `rows`. The authorization
82 /// of the plan that embeds these rows demands each source's
83 /// requirement. Empty for a named provider and for constant rows,
84 /// which `provider` and the tenant-wide fallback cover.
85 #[serde(default)]
86 sources: Vec<crate::physical_plan::GatheredSource>,
87 /// Serialized `Vec<ScanFilter>` (MessagePack). Empty = no predicate.
88 #[serde(default)]
89 filters: Vec<u8>,
90 /// Output column names to keep. Empty = emit all columns.
91 #[serde(default)]
92 projection: Vec<String>,
93 /// Serialized `Vec<ComputedColumn>` (MessagePack), same encoding as
94 /// `DocumentOp::Scan::computed_columns`. Empty = none.
95 #[serde(default)]
96 computed_columns: Vec<u8>,
97 /// Serialized `Vec<WindowFuncSpec>` (MessagePack), same encoding as
98 /// `DocumentOp::Scan::window_functions`. Empty = none.
99 #[serde(default)]
100 window_functions: Vec<u8>,
101 /// ORDER BY terms, each an expression. Empty = unordered.
102 #[serde(default)]
103 sort_keys: Vec<crate::physical_plan::SortKeySpec>,
104 /// Maximum rows to emit after offset/sort/distinct/projection.
105 /// `None` = unlimited.
106 #[serde(default)]
107 limit: Option<usize>,
108 /// Number of rows to skip before applying limit.
109 #[serde(default)]
110 offset: usize,
111 /// SQL DISTINCT: deduplicate on the projected row.
112 #[serde(default)]
113 distinct: bool,
114 },
115
116 /// Relational post-processing over a materialized child plan.
117 ///
118 /// Coordinator-resolved, exactly like [`QueryOp::Exchange`]: at resolve time
119 /// the coordinator gathers `input`'s rows, flattens them to the relational
120 /// row shape, and rewrites this node into a [`QueryOp::ProviderScan`]
121 /// carrying the same relational parameters — which applies
122 /// filter→offset→sort→distinct→project→limit on a single core. A Data-Plane
123 /// core must NEVER see this node (defensive error if it does).
124 ///
125 /// Lowered from `SqlPlan::Subquery`: an outer `ORDER BY` / `OFFSET` /
126 /// `DISTINCT` / post-reorder `LIMIT` over a subquery/derived-table body
127 /// whose leaf plan could not absorb those constraints. Constraints the leaf
128 /// DID absorb (a `WHERE` pushed into a search engine, an unordered `LIMIT`
129 /// folded into `top_k`) are applied by `input` and not repeated here.
130 PostProcess {
131 /// The subquery body to materialize. Wrapped in `Exchange{Gather}` by
132 /// the converter when the body is a sharded source, so the gather runs
133 /// exactly once over the full union before post-processing.
134 input: Box<crate::physical_plan::PhysicalPlan>,
135 /// Serialized `Vec<ScanFilter>` (MessagePack). Empty = no predicate.
136 #[serde(default)]
137 filters: Vec<u8>,
138 /// Output column names to keep. Empty = emit all columns.
139 #[serde(default)]
140 projection: Vec<String>,
141 /// Serialized `Vec<ComputedColumn>` (MessagePack), same encoding as
142 /// `DocumentOp::Scan::computed_columns`. Empty = none.
143 #[serde(default)]
144 computed_columns: Vec<u8>,
145 /// Serialized `Vec<WindowFuncSpec>` (MessagePack), same encoding as
146 /// `DocumentOp::Scan::window_functions`. Empty = none.
147 #[serde(default)]
148 window_functions: Vec<u8>,
149 /// ORDER BY terms, each an expression. Empty = unordered.
150 #[serde(default)]
151 sort_keys: Vec<crate::physical_plan::SortKeySpec>,
152 /// Maximum rows to emit after offset/sort/distinct/projection.
153 /// `None` = unlimited.
154 #[serde(default)]
155 limit: Option<usize>,
156 /// Number of rows to skip before applying limit.
157 #[serde(default)]
158 offset: usize,
159 /// SQL DISTINCT: deduplicate on the projected row.
160 #[serde(default)]
161 distinct: bool,
162 },
163
164 /// Set operation over N materialized children. Coordinator-only: the
165 /// resolver materializes every child and merges the rows into one
166 /// `ProviderScan` before dispatch. A Data-Plane core never sees this node.
167 ///
168 /// Lowered from a derived-table body that is `UNION [ALL]`,
169 /// `INTERSECT [ALL]`, or `EXCEPT [ALL]`, so the body is one relation for
170 /// an outer [`QueryOp::PostProcess`] or input-sourced [`QueryOp::Aggregate`].
171 SetOp {
172 /// Child relations in SQL order. Each sharded child is wrapped in
173 /// `Exchange{Gather}` by the converter so its gather runs once.
174 inputs: Vec<crate::physical_plan::PhysicalPlan>,
175 /// Which set operation merges the inputs.
176 op: crate::physical_plan::SetOpKind,
177 },
178
179 /// Aggregate: GROUP BY + aggregate functions.
180 Aggregate {
181 collection: QualifiedCollection,
182 /// Optional sub-plan whose decoded rows are aggregated instead of
183 /// scanning `collection` per-shard. `Some` = an input-sourced
184 /// aggregate over a materialized relation: a catalog `ProviderScan`,
185 /// or any derived-table body the coordinator materializes into a
186 /// `ProviderScan` before dispatch. Coordinator-local, never broadcast
187 /// (see `is_sharded_source`). `None` = scan the named `collection` on
188 /// every shard. `collection` stays populated in both cases so
189 /// downstream RLS / permission / classification continue to read it;
190 /// the executor prefers `input` when present.
191 #[serde(default)]
192 input: Option<Box<crate::physical_plan::PhysicalPlan>>,
193 group_by: Vec<GroupKeySpec>,
194 aggregates: Vec<AggregateSpec>,
195 filters: Vec<u8>,
196 /// HAVING predicates applied post-aggregation.
197 having: Vec<u8>,
198 limit: usize,
199 sub_group_by: Vec<String>,
200 sub_aggregates: Vec<AggregateSpec>,
201 /// ROLLUP / CUBE / GROUPING SETS expansion. Each inner `Vec<u32>` is
202 /// one grouping set — the indices into `group_by` that are *present*
203 /// (non-NULL) for rows in that set. Empty outer vec = plain single-set
204 /// GROUP BY (no null-filling needed).
205 grouping_sets: Vec<Vec<u32>>,
206 /// Post-aggregation sort keys: `(column_name, ascending)`.
207 /// Empty = preserve executor's natural order (hash-map iteration
208 /// for plain GROUP BY). The executor applies the sort after all
209 /// groups are finalized and HAVING is filtered.
210 #[serde(default)]
211 sort_keys: Vec<crate::physical_plan::SortKeySpec>,
212 },
213
214 /// Partial aggregate: each core computes locally, Control Plane merges.
215 PartialAggregate {
216 collection: QualifiedCollection,
217 group_by: Vec<String>,
218 aggregates: Vec<AggregateSpec>,
219 filters: Vec<u8>,
220 },
221
222 /// Partial aggregate STATE producer (distributed GROUP BY shuffle, map side).
223 ///
224 /// Accumulates exactly like [`QueryOp::PartialAggregate`] — scan the named
225 /// `collection`, apply `filters`, build per-group `GroupState` accumulators
226 /// keyed on `group_by` — but DOES NOT finalize. Instead it emits one row
227 /// PER GROUP of the flat shape:
228 ///
229 /// ```text
230 /// { <group_by[0]>: value_0, ..., "__agg_state": <bytes> }
231 /// ```
232 ///
233 /// where `__agg_state` is the serialized partial `GroupState` for that
234 /// group. A downstream [`QueryOp::ShuffleAggregateConsume`] merges these
235 /// partial states (from every producer shard/node) and finalizes them.
236 ///
237 /// This op carries NO node-local paths — it reads only the named collection
238 /// (or its `input` sub-plan) — so it is wire-shippable: a coordinator embeds
239 /// it in a producer plan and dispatches it to a remote node's Data Plane.
240 PartialAggregateState {
241 collection: QualifiedCollection,
242 /// Optional sub-plan whose decoded rows are aggregated instead of
243 /// scanning `collection` per-shard. Mirrors [`QueryOp::Aggregate`]'s
244 /// `input`: `Some` runs the producer over the sub-plan's rows (e.g. a
245 /// coordinator-materialized `ProviderScan`), `None` scans the named
246 /// `collection`. `collection` stays populated either way.
247 #[serde(default)]
248 input: Option<Box<crate::physical_plan::PhysicalPlan>>,
249 group_by: Vec<GroupKeySpec>,
250 aggregates: Vec<AggregateSpec>,
251 filters: Vec<u8>,
252 },
253
254 /// Hash join: build hash map on right, probe with left.
255 ///
256 /// `left_input`/`right_input` carry a resolved child plan (an Exchange
257 /// child during planning, or an embedded `ProviderScan` after coordinator
258 /// resolution). When `None` the side is scanned locally by collection name.
259 HashJoin {
260 left_collection: QualifiedCollection,
261 right_collection: QualifiedCollection,
262 left_alias: Option<String>,
263 right_alias: Option<String>,
264 on: Vec<(String, String)>,
265 join_type: String,
266 limit: usize,
267 /// Post-join projection: column names to keep (empty = all).
268 projection: Vec<JoinProjection>,
269 /// MessagePack-encoded computed projection expressions. When present,
270 /// these represent the complete SELECT list in output order.
271 computed_projection: Vec<u8>,
272 /// Residual `ON` predicates evaluated for each equi-key candidate
273 /// before outer-join match accounting (MessagePack).
274 join_filters: Vec<u8>,
275 /// Post-join WHERE filter predicates (MessagePack).
276 post_filters: Vec<u8>,
277 /// Resolved child plan for the left side. An Exchange child during
278 /// planning; a `ProviderScan` after coordinator resolution. `None`
279 /// means the left side is scanned locally by `left_collection`.
280 left_input: Option<Box<crate::physical_plan::PhysicalPlan>>,
281 /// Resolved child plan for the right side. Same semantics as
282 /// `left_input` but applied to `right_collection`.
283 right_input: Option<Box<crate::physical_plan::PhysicalPlan>>,
284 /// Bitmap-producer sub-plan for the left side. When set, the executor
285 /// executes this plan first, collects surrogates from all returned rows,
286 /// and injects the resulting bitmap into the probe-side prefilter before
287 /// scanning. `None` = no bitmap pushdown for the left side.
288 left_bitmap: Option<Box<crate::physical_plan::PhysicalPlan>>,
289 /// Bitmap-producer sub-plan for the right side. Same semantics as
290 /// `left_bitmap` but applied to the right (probe) collection.
291 right_bitmap: Option<Box<crate::physical_plan::PhysicalPlan>>,
292 /// Row-level-security filters for rows scanned from
293 /// `left_collection` locally — i.e. when `left_input` is `None`.
294 ///
295 /// When `left_input` is `Some`, the child plan carries its own RLS and
296 /// this stays empty. The filters apply per side *before* the join, not
297 /// after it: an excluded row must neither match a partner nor produce a
298 /// null-extended outer row, and a post-join filter can do neither.
299 left_rls_filters: Vec<u8>,
300 /// Row-level-security filters for rows scanned from
301 /// `right_collection` locally. Same semantics as `left_rls_filters`.
302 right_rls_filters: Vec<u8>,
303 /// Predicates from the left side's own `WHERE`, applied to rows
304 /// scanned from `left_collection` locally. Empty when `left_input` is
305 /// `Some`, because the child plan then carries its own predicates.
306 /// Applied per side before the join, for the same reason as
307 /// `left_rls_filters`.
308 left_scan_filters: Vec<u8>,
309 /// Predicates from the right side's own `WHERE`. Same semantics as
310 /// `left_scan_filters`.
311 right_scan_filters: Vec<u8>,
312 },
313
314 /// Cross-node shuffle-join CONSUMER (E4b): run the node-local grace-hash
315 /// join over two already-staged shuffle frame files.
316 ///
317 /// This variant is BUILT LOCALLY by the part-owner node's consume hook with
318 /// resolved local absolute paths and dispatched to the SAME node's Data
319 /// Plane. It carries node-local filesystem paths (as plain strings) and must
320 /// NEVER be serialized cross-node — the wire encoder rejects it for exactly
321 /// this reason (see `physical_plan::wire::encode`).
322 ///
323 /// The owned fields mirror the borrowed `JoinParams` the Data-Plane handler
324 /// (`execute_shuffle_join`) reconstructs: `on` (equi-join key pairs),
325 /// `join_type`, `limit`, and the two column qualifiers. `build_path` /
326 /// `probe_path` are the staged build (right) and probe (left) frame files.
327 ShuffleJoinConsume {
328 /// Local absolute path to the staged BUILD (right) side frame file.
329 build_path: String,
330 /// Local absolute path to the staged PROBE (left) side frame file.
331 probe_path: String,
332 /// Equi-join key pairs `(left_key, right_key)`.
333 on: Vec<(String, String)>,
334 /// Join type (`inner` / `left` / `right` / `full`). `cross`/keyless is
335 /// rejected by the handler.
336 join_type: String,
337 /// Output row cap. `usize::MAX` = no explicit LIMIT (budget-bounded).
338 limit: usize,
339 /// Column qualifier (prefix) for probe-side (left) columns.
340 probe_qualifier: String,
341 /// Column qualifier (prefix) for build-side (right/index) columns.
342 index_qualifier: String,
343 },
344
345 /// Distributed GROUP BY shuffle CONSUMER (reduce side): merge the staged
346 /// partial `GroupState`s produced by [`QueryOp::PartialAggregateState`]
347 /// producers, then finalize, HAVING-filter, sort, and LIMIT.
348 ///
349 /// `state_path` is a node-local frame file written by the shuffle receive
350 /// path: a sequence of `[u32 LE len][row-bytes]` frames, one msgpack row per
351 /// frame, each row the flat `{<group_by cols>, "__agg_state": <bytes>}` shape
352 /// a `PartialAggregateState` producer emits. The handler rebuilds each
353 /// group key (byte-identically to the accumulate path), decodes
354 /// `__agg_state`, and `merge_from`s into a consolidated per-group map before
355 /// running the same finalize tail as a normal aggregate.
356 ///
357 /// Like [`QueryOp::ShuffleJoinConsume`], this variant carries a node-local
358 /// path and is BUILT LOCALLY by the consume hook for dispatch to the SAME
359 /// node's Data Plane — it must NEVER be serialized cross-node (the wire
360 /// encoder rejects it; see `physical_plan::wire::encode`).
361 ShuffleAggregateConsume {
362 /// Local absolute path to the staged partial-state frame file.
363 state_path: String,
364 /// GROUP BY columns (the key the partial states were grouped on).
365 group_by: Vec<String>,
366 /// Aggregate specs (must match the producers' specs, in order).
367 aggregates: Vec<AggregateSpec>,
368 /// HAVING predicates applied post-merge (serialized `Vec<ScanFilter>`).
369 having: Vec<u8>,
370 /// Output row cap after sort. `usize::MAX` = no explicit LIMIT.
371 limit: usize,
372 /// Post-aggregation ORDER BY terms, each an expression.
373 sort_keys: Vec<crate::physical_plan::SortKeySpec>,
374 },
375
376 /// Nested loop join: fallback for non-equi joins.
377 NestedLoopJoin {
378 left_collection: QualifiedCollection,
379 right_collection: QualifiedCollection,
380 /// Join condition as serialized `Vec<ScanFilter>`.
381 condition: Vec<u8>,
382 join_type: String,
383 limit: usize,
384 /// Row-level-security filters for rows scanned from `left_collection`.
385 /// Applied per side before the join, for the same reason as
386 /// `QueryOp::HashJoin::left_rls_filters`.
387 left_rls_filters: Vec<u8>,
388 /// Row-level-security filters for rows scanned from `right_collection`.
389 right_rls_filters: Vec<u8>,
390 /// Resolved rows for the left side, set by the coordinator inside a
391 /// transaction so the side reads its staged writes. `None` means the
392 /// side is scanned locally by `left_collection`. When set, the child
393 /// carries the side's read policy and `left_rls_filters` is empty.
394 left_input: Option<Box<crate::physical_plan::PhysicalPlan>>,
395 /// Resolved rows for the right side. Same semantics as `left_input`.
396 right_input: Option<Box<crate::physical_plan::PhysicalPlan>>,
397 },
398
399 /// Sort-merge join: both sides pre-sorted by join key.
400 /// Optimal when both collections have index-ordered scans or
401 /// when the planner sorts both sides before joining.
402 SortMergeJoin {
403 left_collection: QualifiedCollection,
404 right_collection: QualifiedCollection,
405 on: Vec<(String, String)>,
406 join_type: String,
407 limit: usize,
408 /// If true, both sides are assumed pre-sorted by join key (skip sort phase).
409 pre_sorted: bool,
410 /// Row-level-security filters for rows scanned from `left_collection`.
411 left_rls_filters: Vec<u8>,
412 /// Row-level-security filters for rows scanned from `right_collection`.
413 right_rls_filters: Vec<u8>,
414 /// Resolved rows for the left side. Same semantics as
415 /// `QueryOp::NestedLoopJoin::left_input`.
416 left_input: Option<Box<crate::physical_plan::PhysicalPlan>>,
417 /// Resolved rows for the right side. Same semantics as `left_input`.
418 right_input: Option<Box<crate::physical_plan::PhysicalPlan>>,
419 },
420
421 /// Multi-facet aggregation: compute facet counts for multiple fields
422 /// in a single query, sharing the filter evaluation across all facets.
423 FacetCounts {
424 collection: QualifiedCollection,
425 /// Serialized `Vec<ScanFilter>` predicates (MessagePack).
426 filters: Vec<u8>,
427 /// Field names to facet on (each produces a `[{value, count}]` array).
428 fields: Vec<String>,
429 /// Maximum number of values to return per facet field (0 = unlimited).
430 limit_per_facet: usize,
431 },
432
433 /// Recursive CTE: iterative fixed-point execution.
434 ///
435 /// Executes the base query once, then repeatedly executes the recursive
436 /// query using the previous iteration's results as the working table,
437 /// until no new rows are produced (fixed point).
438 RecursiveScan {
439 /// Collection for the recursive scan.
440 collection: QualifiedCollection,
441 /// Base query filters (seeded once).
442 base_filters: Vec<u8>,
443 /// Recursive step filters (applied to working table each iteration).
444 recursive_filters: Vec<u8>,
445 /// Equi-join link for tree-traversal recursion:
446 /// `(collection_field, working_table_field)`.
447 /// Each iteration finds rows where `collection_field` value
448 /// matches a `working_table_field` value from the previous iteration.
449 join_link: Option<(String, String)>,
450 /// Maximum iterations to prevent infinite loops. Default: 100.
451 max_iterations: usize,
452 /// Whether to deduplicate results (UNION vs UNION ALL).
453 distinct: bool,
454 limit: usize,
455 },
456
457 /// Value-generating recursive CTE: iterative expression evaluation.
458 ///
459 /// No collection is needed. The executor evaluates the anchor expressions
460 /// once to produce the first row, then repeatedly applies the step
461 /// expressions to the previous row until the condition becomes false,
462 /// a fixed point is reached, or `max_depth` is exceeded (typed error).
463 RecursiveValue {
464 /// CTE name (used in error messages).
465 cte_name: String,
466 /// Column names (length == `init_exprs.len()` == `step_exprs.len()`).
467 columns: Vec<String>,
468 /// Anchor SELECT expressions as raw SQL text.
469 init_exprs: Vec<String>,
470 /// Recursive step SELECT expressions as raw SQL text.
471 step_exprs: Vec<String>,
472 /// Optional WHERE condition as raw SQL text.
473 condition: Option<String>,
474 /// Maximum iterations before returning a depth-exceeded error.
475 max_depth: usize,
476 /// Whether to deduplicate (UNION vs UNION ALL).
477 distinct: bool,
478 },
479
480 /// LATERAL equi-correlated top-K: scan `inner_collection` once per outer
481 /// row, applying the equi-correlation as an equality filter, then return
482 /// the top `inner_limit` rows ordered by `inner_order_by`.
483 ///
484 /// The executor first runs `outer_plan` to materialise outer rows, then
485 /// for each outer row injects the equi-correlation as an equality filter
486 /// on `inner_collection`, applies `inner_order_by`, and keeps the top
487 /// `inner_limit` rows. Output rows are `(outer_row merged with inner_row)`.
488 ///
489 /// `correlation_keys` are `(outer_col, inner_col)` equi-join pairs.
490 LateralTopK {
491 /// Sub-plan that produces the outer (driving) rows.
492 outer_plan: Box<crate::physical_plan::PhysicalPlan>,
493 /// Alias qualifying the outer columns in output rows.
494 outer_alias: String,
495 /// Inner collection to scan per outer row.
496 inner_collection: QualifiedCollection,
497 /// Non-correlated filters applied to every inner scan (msgpack bytes).
498 inner_filters: Vec<u8>,
499 /// ORDER BY terms for the inner per-outer-row result.
500 inner_order_by: Vec<crate::physical_plan::SortKeySpec>,
501 /// Maximum inner rows per outer row.
502 inner_limit: usize,
503 /// Equi-join pairs `(outer_col, inner_col)`.
504 correlation_keys: Vec<(String, String)>,
505 /// Alias qualifying inner columns in output rows.
506 lateral_alias: String,
507 /// Output projection (empty = all columns).
508 projection: Vec<JoinProjection>,
509 /// LEFT join semantics: preserve outer rows when inner is empty.
510 left_join: bool,
511 },
512
513 /// LATERAL nested-loop: for each outer row, re-execute the inner plan
514 /// with correlated values injected as additional equality filters.
515 ///
516 /// The executor runs `outer_plan`, then for each outer row reads the
517 /// `outer_col` values from `correlation_predicates`, appends equality
518 /// filters on the corresponding `inner_col` fields, and scans
519 /// `inner_collection`.
520 LateralLoop {
521 /// Sub-plan that produces the outer (driving) rows.
522 outer_plan: Box<crate::physical_plan::PhysicalPlan>,
523 /// Alias qualifying the outer columns in output rows.
524 outer_alias: String,
525 /// Inner collection to scan per outer row.
526 inner_collection: QualifiedCollection,
527 /// Base inner filters (non-correlated, msgpack bytes).
528 inner_filters: Vec<u8>,
529 /// Correlated predicates: `(inner_field, outer_field)`.
530 correlation_predicates: Vec<(String, String)>,
531 /// Alias qualifying inner columns in output rows.
532 lateral_alias: String,
533 /// Output projection (empty = all columns).
534 projection: Vec<JoinProjection>,
535 /// LEFT join semantics.
536 left_join: bool,
537 /// Hard cap on outer rows. Queries that exceed this cap return a
538 /// typed `LateralCapExceeded` error instead of silently truncating.
539 outer_row_cap: usize,
540 },
541}