Skip to main content

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}