Skip to main content

risingwave_frontend/binder/relation/
match_recognize.rs

1// Copyright 2026 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::{BTreeSet, HashMap};
16
17use risingwave_common::bail_not_implemented;
18use risingwave_common::catalog::Field;
19use risingwave_common::types::{DataType, Decimal, Interval, ScalarImpl};
20use risingwave_expr::aggregate::PbAggKind;
21use risingwave_sqlparser::ast::{
22    AfterMatchSkip, Expr as AstExpr, Function, FunctionArg, FunctionArgExpr, Ident,
23    MatchRecognizePattern, MatchRecognizeSymbol, Measure, OrderByExpr, RowsPerMatch,
24    SubsetDefinition, SymbolDefinition, TableAlias, TableFactor, Value as AstValue,
25};
26use thiserror_ext::AsReport;
27
28use super::{Binder, Relation};
29use crate::error::Result as RwResult;
30use crate::expr::{
31    AggCall, Expr, ExprImpl, ExprRewriter, ExprType, ExprVisitor, FunctionCall, InputRef, Literal,
32    OrderBy,
33};
34use crate::optimizer::plan_node::generic::PlanAggCall;
35use crate::utils::Condition;
36
37/// One navigation input that a measure expression reads. A measure is lowered to an expression over
38/// a synthetic row whose `i`-th column is produced by `slots[i]`; the executor materializes that row
39/// per match (the column values are only knowable once the match and its per-row labels are found)
40/// and then evaluates the expression.
41#[derive(Debug, Clone, PartialEq, Eq, Hash)]
42pub struct MeasureSlot {
43    pub kind: MeasureSlotKind,
44    /// Pattern variables this slot navigates over: one for a plain variable, several for a `SUBSET`
45    /// union variable. A row matches if its label is any of these. Empty for `CLASSIFIER`.
46    pub vars: Vec<String>,
47    /// Input column index to read. Ignored for [`MeasureSlotKind::Classifier`].
48    pub col_idx: usize,
49    /// The slot's output type: the input column type for navigation, varchar for classifier.
50    pub data_type: DataType,
51    /// The aggregate to run for [`MeasureSlotKind::Sum`], over a single input column (the projected
52    /// `col_idx`) of the rows whose label is in `vars`. `None` for other kinds.
53    pub agg: Option<PlanAggCall>,
54}
55
56/// How a [`MeasureSlot`] resolves against the rows of a match. This is the wire enum used
57/// directly (the variants are documented in `stream_plan.proto`): a parallel binder-side enum
58/// would only add a conversion layer for the plan node to keep in lockstep.
59pub use risingwave_pb::stream_plan::match_recognize_measure_slot::Kind as MeasureSlotKind;
60
61/// A bound `MEASURES` item: an expression over the per-match synthetic row, its navigation slots,
62/// and the output name.
63#[derive(Debug, Clone, PartialEq, Eq, Hash)]
64pub struct BoundMeasure {
65    /// Expression over the synthetic row: `InputRef(i)` reads `slots[i]`.
66    pub expr: ExprImpl,
67    pub name: String,
68    pub slots: Vec<MeasureSlot>,
69}
70
71/// How a [`DefineSlot`] resolves against the row being tested for membership in a pattern
72/// variable. The wire enum, used directly (documented in `stream_plan.proto`); see
73/// [`MeasureSlotKind`] for why there is no parallel binder-side enum.
74pub use risingwave_pb::stream_plan::match_recognize_define_slot::Kind as DefineSlotKind;
75
76/// One input a `DEFINE` predicate reads. A predicate is lowered to an expression over a synthetic
77/// row whose `i`-th column is produced by `slots[i]`; the executor materializes that row for each
78/// candidate row from the sorted partition and the in-progress match's labels.
79#[derive(Debug, Clone, PartialEq, Eq, Hash)]
80pub struct DefineSlot {
81    pub kind: DefineSlotKind,
82    /// Pattern variables for `RunningFirst`/`RunningLast` (several for a `SUBSET`); empty otherwise.
83    pub vars: Vec<String>,
84    /// Input column index to read.
85    pub col_idx: usize,
86    /// Physical offset for `Prev`/`Next` (>= 1); `0` for the other kinds.
87    pub offset: usize,
88}
89
90/// A bound `DEFINE` item: a pattern variable, the predicate over its [`DefineSlot`]s, and the slots.
91#[derive(Debug, Clone, PartialEq, Eq, Hash)]
92pub struct BoundSymbolDefinition {
93    pub symbol: String,
94    /// Predicate over the synthetic row: `InputRef(i)` reads `slots[i]`.
95    pub definition: ExprImpl,
96    pub slots: Vec<DefineSlot>,
97}
98
99#[derive(Debug, Clone)]
100pub struct BoundMatchRecognize {
101    pub input: Relation,
102    pub partition_by: Vec<ExprImpl>,
103    pub order_by: Vec<ExprImpl>,
104    pub measures: Vec<BoundMeasure>,
105    pub rows_per_match: Option<RowsPerMatch>,
106    pub after_match_skip: Option<AfterMatchSkip>,
107    pub pattern: MatchRecognizePattern,
108    pub defines: Vec<BoundSymbolDefinition>,
109    /// `WITHIN` span check, lowered to a predicate over a synthetic `[last_order_key,
110    /// first_order_key]` row: `InputRef(0) - InputRef(1) <= <interval>`. `None` when omitted.
111    pub within: Option<ExprImpl>,
112    /// `WITHIN` deadline expr, `first_order_key + <interval>`, over a synthetic `[first_order_key]`
113    /// row (`InputRef(0) + <interval>`): the watermark at which a partial starting at that row
114    /// expires. Drives idle-partition eviction. `None` when there is no `WITHIN`.
115    pub within_deadline: Option<ExprImpl>,
116}
117
118impl BoundMatchRecognize {
119    /// Every bound expression this node carries, in one place, so relation traversals
120    /// (correlation checks, recursive expression rewrites) cannot silently skip a field. The
121    /// measure/define/WITHIN expressions are over synthetic slot rows — their `InputRef`s index
122    /// slots, not the input schema — but they can still carry `CorrelatedInputRef`s from an
123    /// enclosing query and are subject to the same expression-local rewrites as everything else.
124    pub fn exprs(&self) -> impl Iterator<Item = &ExprImpl> {
125        self.partition_by
126            .iter()
127            .chain(self.order_by.iter())
128            .chain(self.measures.iter().map(|m| &m.expr))
129            .chain(self.defines.iter().map(|d| &d.definition))
130            .chain(self.within.iter())
131            .chain(self.within_deadline.iter())
132    }
133
134    /// See [`BoundMatchRecognize::exprs`].
135    pub fn exprs_mut(&mut self) -> impl Iterator<Item = &mut ExprImpl> {
136        self.partition_by
137            .iter_mut()
138            .chain(self.order_by.iter_mut())
139            .chain(self.measures.iter_mut().map(|m| &mut m.expr))
140            .chain(self.defines.iter_mut().map(|d| &mut d.definition))
141            .chain(self.within.iter_mut())
142            .chain(self.within_deadline.iter_mut())
143    }
144}
145
146impl Binder {
147    #[allow(clippy::too_many_arguments)]
148    pub(super) fn bind_match_recognize(
149        &mut self,
150        table: &TableFactor,
151        partition_by: &[AstExpr],
152        order_by: &[OrderByExpr],
153        measures: &[Measure],
154        rows_per_match: &Option<RowsPerMatch>,
155        after_match_skip: &Option<AfterMatchSkip>,
156        pattern: &MatchRecognizePattern,
157        within: &Option<AstExpr>,
158        subsets: &[SubsetDefinition],
159        symbols: &[SymbolDefinition],
160        alias: Option<&TableAlias>,
161    ) -> RwResult<BoundMatchRecognize> {
162        // ALL ROWS PER MATCH is not in the v1 subset.
163        if matches!(rows_per_match, Some(RowsPerMatch::AllRows)) {
164            bail_not_implemented!("ALL ROWS PER MATCH");
165        }
166        // The pattern is expanded eagerly into NFA states, on the compute node, when the actor is
167        // built. Validate the bounds here so an unusable pattern is a rejected statement rather than
168        // a committed materialized view whose actors die on creation and on every recovery.
169        validate_pattern(pattern)?;
170        self.push_context();
171
172        // Bind the input. This registers the input's columns in the current context.
173        let input = self.bind_table_factor(table)?;
174
175        // PARTITION BY / ORDER BY are evaluated over the input rows, so bind them while only the
176        // input is in scope — unqualified column references resolve unambiguously here.
177        let partition_by = partition_by
178            .iter()
179            .map(|e| self.bind_expr(e))
180            .collect::<RwResult<Vec<_>>>()?;
181        // v1 supports only the default ordering (ascending, default null placement). The plan, proto
182        // and executor carry only the ORDER BY *expressions* — not direction or null placement — so a
183        // `DESC` or an explicit `NULLS FIRST|LAST` would be silently dropped and the executor would
184        // sort the wrong way. Reject it explicitly rather than compile to incorrect behaviour.
185        for o in order_by {
186            if o.asc == Some(false) || o.nulls_first.is_some() {
187                bail_not_implemented!(
188                    "MATCH_RECOGNIZE ORDER BY currently supports only ascending order with default \
189                     null placement (no DESC or explicit NULLS FIRST/LAST)"
190                );
191            }
192        }
193        let order_by = order_by
194            .iter()
195            .map(|o| self.bind_expr(&o.expr))
196            .collect::<RwResult<Vec<_>>>()?;
197
198        // WITHIN: lower the bound to `last <= first + bound` over a synthetic [last, first] row
199        // and to the deadline `first + bound`, using the leading ORDER BY column's type.
200        let (within, within_deadline) = match within {
201            Some(e) => {
202                let bound = self.bind_expr(e)?;
203                // The lowered predicate/deadline are evaluated over synthetic order-key rows, where
204                // any reference into the original input row is out of bounds. Only a constant bound
205                // is meaningful there, so reject everything else at bind time.
206                if !bound.is_const() {
207                    bail_not_implemented!(
208                        "MATCH_RECOGNIZE WITHIN bound must be a constant expression; \
209                         input column references are not supported"
210                    );
211                }
212                // The bound is the maximum span of a match, compared as `last - first <= bound`
213                // and used as the eviction deadline `first + bound`. A NULL bound makes the span
214                // predicate never-true-nor-false — it silently bounds NOTHING while reading as if
215                // it did — and a zero or negative bound can never hold a multi-row match, leaving
216                // a permanently empty view. All three are certainly not what the author meant, so
217                // reject them at bind time. (Positivity is checked for the carrier types the
218                // `order_key + bound` arithmetic admits; an exotic type falls through to runtime
219                // semantics.)
220                let folded_bound = bound.try_fold_const().expect("checked is_const")?;
221                let is_positive = match &folded_bound {
222                    None => Some(false),
223                    Some(scalar) => match scalar {
224                        ScalarImpl::Int16(v) => Some(*v > 0),
225                        ScalarImpl::Int32(v) => Some(*v > 0),
226                        ScalarImpl::Int64(v) => Some(*v > 0),
227                        ScalarImpl::Float32(v) => Some(v.into_inner() > 0.0),
228                        ScalarImpl::Float64(v) => Some(v.into_inner() > 0.0),
229                        ScalarImpl::Decimal(d) => Some(*d > Decimal::from(0)),
230                        // Positive in total is not enough for an interval: `timestamp + interval`
231                        // adds months, days and microseconds as separate checked steps, so a
232                        // mixed-sign bound (`'1 month -29 days'`) can overflow on one component
233                        // while its true sum is representable — and the executor reads an
234                        // out-of-range deadline as a window that never closes, which would then
235                        // admit rows past the real deadline. Requiring every component to be
236                        // non-negative makes the deadline monotone in the bound, so "out of range"
237                        // means exactly "past every representable order key".
238                        ScalarImpl::Interval(iv) => Some(
239                            iv.months() >= 0
240                                && iv.days() >= 0
241                                && iv.usecs() >= 0
242                                && *iv != Interval::from_month_day_usec(0, 0, 0),
243                        ),
244                        _ => None,
245                    },
246                };
247                if is_positive == Some(false) {
248                    return Err(crate::error::ErrorCode::NotSupported(
249                        "a MATCH_RECOGNIZE WITHIN bound that is NULL, zero or negative, or an \
250                         interval with a negative component"
251                            .to_owned(),
252                        "the bound is the maximum span of a match; a non-positive bound can never \
253                         hold a multi-row match (the view would stay empty) and a NULL bound \
254                         bounds nothing — use a positive constant interval whose months, days and \
255                         seconds are all non-negative"
256                            .to_owned(),
257                    )
258                    .into());
259                }
260                // Calendar-month addition can reverse the order of deadlines at month end.
261                // The finality gate relies on earlier starts having no later deadline, so reject
262                // month-bearing bounds (including years) after folding constant expressions.
263                if matches!(&folded_bound, Some(ScalarImpl::Interval(iv)) if iv.months() != 0) {
264                    return Err(crate::error::ErrorCode::NotSupported(
265                        "MATCH_RECOGNIZE WITHIN intervals with a nonzero month component"
266                            .to_owned(),
267                        "use an interval with only non-negative days and seconds; calendar months \
268                         and years can make deadlines non-monotone in the match start time"
269                            .to_owned(),
270                    )
271                    .into());
272                }
273                let Some(order_key) = order_by.first() else {
274                    bail_not_implemented!("WITHIN requires an ORDER BY column");
275                };
276                let (predicate, deadline) = lower_within(order_key.return_type(), bound)?;
277                (Some(predicate), Some(deadline))
278            }
279            None => {
280                // No WITHIN: an unmatched partial can be completed by an arbitrarily distant future
281                // row, so it is retained until matched. For patterns whose live partial spans a
282                // bounded number of rows that bounds state by PARTITION BY key cardinality — but a
283                // pattern with an unbounded quantifier that stays satisfiable (`(A+ B)` where rows
284                // keep satisfying A and B never arrives) retains EVERY row of the partition, with
285                // no bound from key cardinality. Warn the author about both regimes (a client
286                // NOTICE, visible in the CREATE output); we do not forbid it, matching SQL
287                // semantics and Flink's behaviour.
288                crate::session::current::notice_to_user(
289                    "MATCH_RECOGNIZE without a WITHIN clause retains unmatched partial matches \
290                     indefinitely. If every partial the pattern can hold spans a bounded number \
291                     of rows, state is bounded by the number of distinct PARTITION BY keys; but a \
292                     pattern with an unbounded quantifier that keeps matching (e.g. (A+ B) on a \
293                     partition where A stays true and B never arrives) retains every row of that \
294                     partition. Add a WITHIN clause to bound state to a time window.",
295                );
296                (None, None)
297            }
298        };
299
300        // Snapshot the input columns so each pattern variable can be registered as an alias over
301        // them. After this, `A.col` (a pattern-variable-qualified reference) resolves to the input
302        // column `col`. The variable association is preserved in the AST for execution; this step
303        // only makes MEASURES/DEFINE type-check.
304        let input_columns: Vec<(bool, Field)> = self
305            .context
306            .columns
307            .iter()
308            .map(|c| (c.is_hidden, c.field.clone()))
309            .collect();
310
311        let input_col_num = input_columns.len();
312        let pattern_variables = {
313            let mut vars = BTreeSet::new();
314            collect_from_pattern(pattern, &mut vars);
315            vars
316        };
317        // A DEFINE for a symbol that never appears in PATTERN is dead: no row can be labeled with
318        // it, so its predicate is never evaluated. Accepting it silently turns a typo into a
319        // pattern that matches something other than what the author wrote (the standard requires
320        // every DEFINE symbol to be a primary pattern variable).
321        for s in symbols {
322            let symbol = s.symbol.real_value();
323            if !pattern_variables.contains(&symbol) {
324                return Err(crate::error::ErrorCode::InvalidInputSyntax(format!(
325                    "DEFINE names `{symbol}`, which does not appear in PATTERN; its predicate \
326                     could never be evaluated"
327                ))
328                .into());
329            }
330        }
331        let variables = collect_pattern_variables(pattern, symbols);
332        for var in &variables {
333            // `collect_pattern_variables` deduplicates, so the only way this registration can fail
334            // is a collision with a relation name already in scope — in practice the MATCH_RECOGNIZE
335            // input itself (`FROM t ... PATTERN (t ...)`). Left unmapped that surfaces as
336            // "internal error: Duplicated table name", which blames the engine for a user-visible
337            // naming clash in legal-looking SQL.
338            self.bind_table_to_context(input_columns.clone(), var.clone(), None, None)
339                .map_err(|_| {
340                    crate::error::ErrorCode::InvalidInputSyntax(format!(
341                        "pattern variable `{var}` collides with the name of a relation in scope \
342                         (the MATCH_RECOGNIZE input table, most likely); rename the variable or \
343                         give the input a different alias"
344                    ))
345                })?;
346        }
347
348        // SUBSET union variables: each must be made of declared pattern variables, and is registered
349        // as a further alias block (after the base variables) so `U.col` type-checks. `alias_names`
350        // records the registration order so a measure InputRef can be decoded back to its variable.
351        let mut alias_names = variables.clone();
352        let mut subset_defs: Vec<(String, Vec<String>)> = Vec::with_capacity(subsets.len());
353        for s in subsets {
354            let name = s.name.real_value();
355            // A SUBSET alias sharing a name with a pattern variable or an earlier SUBSET would
356            // silently shadow it in every MEASURES/DEFINE reference — reject at bind time.
357            if alias_names.contains(&name) {
358                return Err(crate::error::ErrorCode::InvalidInputSyntax(format!(
359                    "SUBSET name `{name}` collides with a pattern variable or another SUBSET"
360                ))
361                .into());
362            }
363            let members: Vec<String> = s.members.iter().map(|m| m.real_value()).collect();
364            for m in &members {
365                if !variables.contains(m) {
366                    // A plain user error (typo), not a missing feature: SQL:2016 requires SUBSET
367                    // members to be declared pattern variables.
368                    return Err(crate::error::ErrorCode::InvalidInputSyntax(format!(
369                        "SUBSET {name} references unknown pattern variable {m}"
370                    ))
371                    .into());
372                }
373            }
374            self.bind_table_to_context(input_columns.clone(), name.clone(), None, None)
375                .map_err(|_| {
376                    crate::error::ErrorCode::InvalidInputSyntax(format!(
377                        "SUBSET name `{name}` collides with the name of a relation in scope \
378                         (the MATCH_RECOGNIZE input table, most likely); rename it or give the \
379                         input a different alias"
380                    ))
381                })?;
382            alias_names.push(name.clone());
383            subset_defs.push((name, members));
384        }
385        let resolver = VarResolver {
386            input_col_num,
387            alias_names: &alias_names,
388            subset_defs: &subset_defs,
389        };
390
391        // AFTER MATCH SKIP TO FIRST/LAST <var> must name a variable that appears in PATTERN: the skip
392        // target is looked up among the labels of a completed match, so a variable absent from the
393        // pattern can never be found there and the executor would silently fall back to skipping
394        // past the last row on every match. (A DEFINE-only symbol cannot reach here — it is
395        // rejected above — so absence from the pattern means the name is unknown outright.)
396        if let Some(AfterMatchSkip::ToFirst(sym) | AfterMatchSkip::ToLast(sym)) = after_match_skip {
397            let target = sym.real_value();
398            if !pattern_variables.contains(&target) {
399                bail_not_implemented!(
400                    "AFTER MATCH SKIP TO FIRST/LAST references unknown pattern variable {}",
401                    target
402                );
403            }
404        }
405
406        // Each pattern variable was registered as an identical alias block over the input columns,
407        // DEFINE predicates: <symbol> AS <condition>. Each is lowered to an expression over a
408        // synthetic row of navigation slots (the candidate row's columns, physical PREV/NEXT, and
409        // running references to other variables), evaluated per candidate during matching.
410        let input_fields: Vec<Field> = input_columns.iter().map(|(_, f)| f.clone()).collect();
411        // Reject a variable defined twice. The executor keys DEFINE predicates by symbol, so
412        // without this
413        // check `DEFINE a AS x > 0, a AS x < 0` silently keeps whichever lowered last — the user's
414        // first predicate simply stops existing, with nothing to say so.
415        {
416            let mut seen = std::collections::HashSet::new();
417            for sym in symbols {
418                let name = sym.symbol.real_value();
419                if !seen.insert(name.clone()) {
420                    return Err(crate::error::ErrorCode::InvalidInputSyntax(format!(
421                        "pattern variable `{name}` is defined more than once in DEFINE"
422                    ))
423                    .into());
424                }
425            }
426        }
427        let defines = symbols
428            .iter()
429            .map(|s| self.lower_define(s, &resolver, &input_fields))
430            .collect::<RwResult<Vec<_>>>()?;
431
432        // Physical `PREV` in `DEFINE` may only read rows inside the match span. Rows before the
433        // match are not retained: eviction deletes consumed rows, and "buffer index 0" is the
434        // eligible-start floor, so a read reaching before the match start would see a real row
435        // before an eviction and `NULL` after it — the same row flipping its verdict on timing
436        // (and `PREV(v) IS NULL`, the idiomatic first-row test, turning a mid-partition row into a
437        // spurious match start). Require each variable using `PREV(.., k)` to sit at least `k`
438        // rows from the match start (see [`min_start_distances`]); everything the walk can prove
439        // stays inside the match is allowed, the rest is rejected until lookbehind retention is
440        // designed as its own change. `NEXT` needs no such rule: its reads go forward and are
441        // bounded by the decision horizon instead.
442        let min_dists = min_start_distances(pattern);
443        for def in &defines {
444            let max_prev = def
445                .slots
446                .iter()
447                .filter(|s| s.kind == DefineSlotKind::Prev)
448                .map(|s| s.offset)
449                .max()
450                .unwrap_or(0);
451            if max_prev == 0 {
452                continue;
453            }
454            // A DEFINE symbol that never appears in PATTERN is never evaluated; skip it.
455            let Some(&dist) = min_dists.get(&def.symbol) else {
456                continue;
457            };
458            if dist < max_prev as u64 {
459                return Err(crate::error::ErrorCode::NotSupported(
460                    format!(
461                        "PREV with offset {max_prev} in DEFINE {}: the variable can occur {dist} \
462                         row(s) from the match start, so the read could reach rows before the \
463                         match, which are not retained",
464                        def.symbol
465                    ),
466                    "ensure the variable is always preceded by at least as many pattern rows as \
467                     the PREV offset, e.g. prefix the pattern with an anchor variable (`x` with \
468                     `x AS TRUE`)"
469                        .to_owned(),
470                )
471                .into());
472            }
473        }
474
475        // MEASURES: <expr> AS <alias>.
476        let measures = measures
477            .iter()
478            .map(|m| self.lower_measure(m, &resolver))
479            .collect::<RwResult<Vec<_>>>()?;
480
481        self.pop_context()?;
482
483        // Output schema (ONE ROW PER MATCH): the partition-by columns followed by the measures.
484        let mut output_columns: Vec<(bool, Field)> = Vec::new();
485        for (i, e) in partition_by.iter().enumerate() {
486            output_columns.push((
487                false,
488                Field::with_name(e.return_type(), format!("partition_{i}")),
489            ));
490        }
491        for m in &measures {
492            // The hidden per-match id below is addressable by explicit name even though it is
493            // excluded from `SELECT *`; a measure with the same alias would make an outer
494            // `SELECT _match_id` ambiguous. Reserve the name.
495            if m.name == "_match_id" {
496                return Err(crate::error::ErrorCode::BindError(
497                    "the measure alias `_match_id` collides with the hidden per-match id column \
498                     MATCH_RECOGNIZE appends; choose another alias"
499                        .to_owned(),
500                )
501                .into());
502            }
503            output_columns.push((
504                false,
505                Field::with_name(m.expr.return_type(), m.name.clone()),
506            ));
507        }
508        // A partition can contain many matches, and two matches may produce byte-identical
509        // (partition + measures) output, so those columns are not a unique key. Append a hidden
510        // per-match id column (filled by the executor) to serve as the unique stream key. It is
511        // hidden, so `SELECT *` still returns only the partition and measure columns.
512        output_columns.push((true, Field::with_name(DataType::Int64, "_match_id")));
513
514        let table_name = match alias {
515            Some(TableAlias { name, .. }) => name.real_value(),
516            None => "match_recognize".to_owned(),
517        };
518        self.bind_table_to_context(output_columns, table_name, None, alias)?;
519
520        Ok(BoundMatchRecognize {
521            input,
522            partition_by,
523            order_by,
524            measures,
525            rows_per_match: rows_per_match.clone(),
526            after_match_skip: after_match_skip.clone(),
527            pattern: pattern.clone(),
528            defines,
529            within,
530            within_deadline,
531        })
532    }
533
534    /// Lowers one `MEASURES` item to an expression over a synthetic per-match row plus the slots
535    /// that produce that row. Pattern-variable column references become navigation slots: bare
536    /// `var.col` and arithmetic over such references resolve to `LAST(var.col)` (FINAL semantics
537    /// under ONE ROW PER MATCH); top-level `FIRST(var.col)` / `LAST(var.col)` and `CLASSIFIER()` are
538    /// supported. Nesting `FIRST`/`LAST`/`CLASSIFIER` inside a larger expression is not yet
539    /// supported (it falls through to ordinary binding and is rejected as an unknown function).
540    fn lower_measure(&mut self, m: &Measure, resolver: &VarResolver<'_>) -> RwResult<BoundMeasure> {
541        let name = m.alias.real_value();
542
543        // CLASSIFIER(): the pattern variable bound to the match's last row.
544        if let AstExpr::Function(func) = &m.expr
545            && func.name.0.len() == 1
546            && func.name.0[0]
547                .real_value()
548                .eq_ignore_ascii_case("classifier")
549        {
550            reject_func_modifiers(func, "MEASURES")?;
551            if !func.arg_list.args.is_empty() {
552                bail_not_implemented!("CLASSIFIER() with arguments in MATCH_RECOGNIZE");
553            }
554            return Ok(BoundMeasure {
555                expr: InputRef::new(0, DataType::Varchar).into(),
556                name,
557                slots: vec![MeasureSlot {
558                    kind: MeasureSlotKind::Classifier,
559                    vars: vec![],
560                    col_idx: 0,
561                    data_type: DataType::Varchar,
562                    agg: None,
563                }],
564            });
565        }
566
567        // Top-level aggregates over the matched rows: COUNT(*), COUNT/MIN/MAX/SUM/AVG(var.col).
568        if let AstExpr::Function(func) = &m.expr
569            && func.name.0.len() == 1
570            && matches!(
571                func.name.0[0].real_value().to_ascii_lowercase().as_str(),
572                "count" | "min" | "max" | "sum" | "avg"
573            )
574        {
575            reject_func_modifiers(func, "MEASURES")?;
576            let agg = func.name.0[0].real_value().to_ascii_lowercase();
577            if func.arg_list.args.len() != 1 {
578                bail_not_implemented!("{}() expects exactly one argument", agg.to_uppercase());
579            }
580            // COUNT(*): every row of the match.
581            if agg == "count"
582                && matches!(
583                    &func.arg_list.args[0],
584                    FunctionArg::Unnamed(FunctionArgExpr::Wildcard(_))
585                )
586            {
587                return Ok(BoundMeasure {
588                    expr: InputRef::new(0, DataType::Int64).into(),
589                    name,
590                    slots: vec![MeasureSlot {
591                        kind: MeasureSlotKind::CountStar,
592                        vars: vec![],
593                        col_idx: 0,
594                        data_type: DataType::Int64,
595                        agg: None,
596                    }],
597                });
598            }
599            // agg(var.col): over the rows labeled `var`.
600            let FunctionArg::Unnamed(FunctionArgExpr::Expr(inner)) = &func.arg_list.args[0] else {
601                bail_not_implemented!(
602                    "{}() argument must be a pattern-variable column",
603                    agg.to_uppercase()
604                );
605            };
606            let ExprImpl::InputRef(r) = self.bind_expr(inner)? else {
607                bail_not_implemented!(
608                    "{}() argument must be a pattern-variable column",
609                    agg.to_uppercase()
610                );
611            };
612            let (vars, col_idx) = resolver.resolve(r.index())?;
613            let col_type = r.data_type.clone();
614            // Validate the call through the regular aggregate registry, so what an aggregate
615            // accepts here is exactly what it accepts anywhere else in RisingWave SQL (e.g. no
616            // `max(boolean)`), and the result type is the registry's — the SQL contract must not
617            // depend on where the aggregate appears.
618            let infer = |kind: PbAggKind| -> RwResult<DataType> {
619                Ok(AggCall::new(
620                    kind.into(),
621                    vec![InputRef::new(0, col_type.clone()).into()],
622                    false,
623                    OrderBy::any(),
624                    Condition::true_cond(),
625                    vec![],
626                )?
627                .return_type)
628            };
629
630            // COUNT / MIN / MAX fold directly over the matched rows in the executor.
631            if let Some((kind, data_type)) = match agg.as_str() {
632                "count" => Some((MeasureSlotKind::Count, DataType::Int64)),
633                "min" => Some((MeasureSlotKind::Min, infer(PbAggKind::Min)?)),
634                "max" => Some((MeasureSlotKind::Max, infer(PbAggKind::Max)?)),
635                _ => None,
636            } {
637                // The executor's Min/Max fold raw column datums (no kernel), so the declared slot
638                // type must BE the column type. Today every registry min/max signature is
639                // `T -> auto`, so validation cannot change the type — but that is the registry's
640                // property, not this code's; fail here rather than mislabel the output if a
641                // type-changing or cast-matched signature ever appears.
642                if matches!(kind, MeasureSlotKind::Min | MeasureSlotKind::Max)
643                    && data_type != col_type
644                {
645                    bail_not_implemented!(
646                        "{}() over {} in MATCH_RECOGNIZE (the aggregate registry returns {}, but \
647                         the per-match evaluation folds column values directly)",
648                        agg.to_uppercase(),
649                        col_type,
650                        data_type
651                    );
652                }
653                return Ok(BoundMeasure {
654                    expr: InputRef::new(0, data_type.clone()).into(),
655                    name,
656                    slots: vec![MeasureSlot {
657                        kind,
658                        vars,
659                        col_idx,
660                        data_type,
661                        agg: None,
662                    }],
663                });
664            }
665
666            // SUM reuses RisingWave's aggregate kernel so the numeric return type stays faithful. The
667            // runtime feeds the kernel a single-column chunk (the projected col), so the call's
668            // argument is an InputRef to column 0. AVG is built on top as cast(sum / count).
669            let sum_type = infer(PbAggKind::Sum)?;
670            let sum_slot = MeasureSlot {
671                kind: MeasureSlotKind::Sum,
672                vars: vars.clone(),
673                col_idx,
674                data_type: sum_type.clone(),
675                agg: Some(PlanAggCall {
676                    agg_type: PbAggKind::Sum.into(),
677                    return_type: sum_type.clone(),
678                    inputs: vec![InputRef::new(0, col_type.clone())],
679                    distinct: false,
680                    order_by: vec![],
681                    filter: Condition::true_cond(),
682                    direct_args: vec![],
683                }),
684            };
685
686            if agg == "sum" {
687                return Ok(BoundMeasure {
688                    expr: InputRef::new(0, sum_type).into(),
689                    name,
690                    slots: vec![sum_slot],
691                });
692            }
693
694            // AVG = CASE WHEN count = 0 THEN NULL ELSE cast(sum AS avg_type) / count END, mirroring
695            // how RisingWave's planner rewrites avg. Slot 0 is the sum, slot 1 the (non-null) count.
696            let avg_type = infer(PbAggKind::Avg)?;
697            let count_slot = MeasureSlot {
698                kind: MeasureSlotKind::Count,
699                vars,
700                col_idx,
701                data_type: DataType::Int64,
702                agg: None,
703            };
704            let sum_ref: ExprImpl = InputRef::new(0, sum_type).into();
705            let count_ref: ExprImpl = InputRef::new(1, DataType::Int64).into();
706            let quotient: ExprImpl = FunctionCall::new(
707                ExprType::Divide,
708                vec![sum_ref.cast_explicit(&avg_type)?, count_ref.clone()],
709            )?
710            .into();
711            let count_is_zero: ExprImpl =
712                FunctionCall::new(ExprType::Equal, vec![count_ref, ExprImpl::literal_int(0)])?
713                    .into();
714            let null: ExprImpl = Literal::new(None, avg_type).into();
715            let expr: ExprImpl =
716                FunctionCall::new(ExprType::Case, vec![count_is_zero, null, quotient])?.into();
717            return Ok(BoundMeasure {
718                expr,
719                name,
720                slots: vec![sum_slot, count_slot],
721            });
722        }
723
724        // Physical PREV/NEXT is DEFINE-only navigation in v1; in MEASURES it would read rows
725        // *outside* the matched rows. Reject it by name — letting it fall through to expression
726        // binding would produce a misleading "function prev does not exist".
727        if let AstExpr::Function(func) = &m.expr
728            && func.name.0.len() == 1
729            && matches!(
730                func.name.0[0].real_value().to_ascii_lowercase().as_str(),
731                "prev" | "next"
732            )
733        {
734            bail_not_implemented!(
735                "physical {}() in MATCH_RECOGNIZE MEASURES (it reads rows outside the match; \
736                 use FIRST/LAST over a pattern variable, or PREV in DEFINE)",
737                func.name.0[0].real_value().to_uppercase()
738            );
739        }
740
741        // Top-level FIRST(var.col) / LAST(var.col).
742        if let AstExpr::Function(func) = &m.expr
743            && func.name.0.len() == 1
744            && matches!(
745                func.name.0[0].real_value().to_ascii_lowercase().as_str(),
746                "first" | "last"
747            )
748        {
749            reject_func_modifiers(func, "MEASURES")?;
750            let kind = if func.name.0[0].real_value().eq_ignore_ascii_case("first") {
751                MeasureSlotKind::First
752            } else {
753                MeasureSlotKind::Last
754            };
755            if func.arg_list.args.len() != 1 {
756                bail_not_implemented!("FIRST/LAST with an offset argument in MATCH_RECOGNIZE");
757            }
758            let FunctionArg::Unnamed(FunctionArgExpr::Expr(inner)) = &func.arg_list.args[0] else {
759                bail_not_implemented!("FIRST/LAST argument must be a pattern-variable column");
760            };
761            let ExprImpl::InputRef(r) = self.bind_expr(inner)? else {
762                bail_not_implemented!("FIRST/LAST argument must be a pattern-variable column");
763            };
764            let (vars, col_idx) = resolver.resolve(r.index())?;
765            let data_type = r.data_type.clone();
766            return Ok(BoundMeasure {
767                expr: InputRef::new(0, data_type.clone()).into(),
768                name,
769                slots: vec![MeasureSlot {
770                    kind,
771                    vars,
772                    col_idx,
773                    data_type,
774                    agg: None,
775                }],
776            });
777        }
778
779        // General case: bare `var.col` and arithmetic over such references. Binding succeeds via the
780        // per-variable alias blocks; each resulting InputRef is then rewritten to a synthetic
781        // LAST(var.col) slot.
782        let expr = self.bind_expr(&m.expr).map_err(|e| {
783            crate::error::ErrorCode::BindError(format!(
784                "{}\nwhile binding MEASURES item `{name}`; pattern variables in scope \
785                 (case-sensitive as written): {}",
786                e.as_report(),
787                resolver.alias_names.join(", ")
788            ))
789        })?;
790        // An aggregate call that is not the whole measure expression has no lowering: the slot
791        // rewriter below maps variable-qualified columns to LAST slots and nothing else, so an
792        // embedded `sum(a.v) + 1` would carry a live AggCall into a scalar-projection plan and fail
793        // (or panic) far from here, after the statement looked accepted.
794        if expr.has_agg_call() {
795            bail_not_implemented!(
796                "an aggregate inside a larger MEASURES expression; aggregates are supported only \
797                 as the whole measure (count/min/max/sum/avg over one pattern-variable column)"
798            );
799        }
800        // Same reasoning as the DEFINE rejection: a measure is evaluated by the executor from a
801        // serialized scalar expression over the slot row; a subquery has no representation there
802        // and would otherwise be carried to the plan-to-proto conversion before failing — after
803        // the statement looked accepted. Rejecting it here also keeps every MATCH_RECOGNIZE
804        // clause expression free of relation references, which `ALTER ... RENAME`'s query
805        // rewriter (`src/meta/src/controller/rename.rs`) relies on to visit only the input table.
806        if expr.has_subquery() {
807            return Err(crate::error::ErrorCode::NotSupported(
808                format!("a subquery in the MEASURES item `{name}`"),
809                "a measure must be a scalar expression over the pattern variables".to_owned(),
810            )
811            .into());
812        }
813        let mut check = InputRefBlockCheck {
814            input_col_num: resolver.input_col_num,
815            // Everything above the variable/subset alias blocks is internal scaffolding — the
816            // `__mr_nav` placeholder relations registered while lowering DEFINE stay in the binder
817            // context, so a user measure can name them. Un-checked, such a reference reaches
818            // `resolve_unchecked` with a block index past `alias_names` and panics the frontend.
819            nav_floor: (1 + resolver.alias_names.len()) * resolver.input_col_num,
820            unqualified: false,
821            internal: false,
822        };
823        check.visit_expr(&expr);
824        if check.unqualified {
825            bail_not_implemented!(
826                "unqualified or non-pattern-variable column reference in MATCH_RECOGNIZE MEASURES"
827            );
828        }
829        if check.internal {
830            return Err(crate::error::ErrorCode::InvalidInputSyntax(
831                "a MATCH_RECOGNIZE MEASURES expression references an internal navigation column \
832                 (`__mr_nav*`); only input columns qualified by a pattern variable are addressable"
833                    .to_owned(),
834            )
835            .into());
836        }
837        let mut rewriter = SlotLoweringRewriter {
838            resolver,
839            slots: Vec::new(),
840        };
841        let expr = rewriter.rewrite_expr(expr);
842        Ok(BoundMeasure {
843            expr,
844            name,
845            slots: rewriter.slots,
846        })
847    }
848
849    /// Lowers one `DEFINE` predicate to an expression over a synthetic row of [`DefineSlot`]s.
850    /// `PREV`/`NEXT`/`FIRST`/`LAST(...)` navigation functions are extracted into slots first (they do
851    /// not bind as ordinary functions); the remaining variable-qualified columns bind via the alias
852    /// blocks and are mapped to self slots (the defined variable / unqualified) or running slots.
853    fn lower_define(
854        &mut self,
855        s: &SymbolDefinition,
856        resolver: &VarResolver<'_>,
857        input_fields: &[Field],
858    ) -> RwResult<BoundSymbolDefinition> {
859        let symbol = s.symbol.real_value();
860        // A per-symbol prefix makes diagnostics for the placeholder relation and columns easier to
861        // associate with the DEFINE item that created them.
862        let prefix = format!("{NAV_TABLE}_{symbol}");
863
864        let mut cond = s.definition.clone();
865        let mut extractor = NavExtractor {
866            input_fields,
867            resolver,
868            symbol: &symbol,
869            prefix: &prefix,
870            nav_slots: Vec::new(),
871            nav_fields: Vec::new(),
872        };
873        extractor.rewrite(&mut cond)?;
874        let NavExtractor {
875            nav_slots,
876            nav_fields,
877            ..
878        } = extractor;
879
880        // Bring the navigation placeholders into scope as a synthetic relation so the predicate
881        // type-checks. Capture the base index first, then restore the context immediately after
882        // binding: another DEFINE must not be able to address this predicate's internal columns.
883        //
884        // Why a synthetic relation rather than a bespoke binder: after extraction each navigation
885        // expression is a fresh column of a known type, and the rest of the predicate is ordinary
886        // SQL over the input/variable columns. Registering the placeholders as a relation lets the
887        // normal `bind_expr` resolve everything in one pass (name resolution, coercion, operator
888        // type-checking) and hand back `InputRef`s we then remap to slots. Building a separate typed
889        // binder for the predicate would duplicate that machinery for no behavioural gain. The
890        // relation name is internal (`__mr_nav_*`) and never escapes this predicate's binding.
891        let nav_base = self.context.columns.len();
892        let bind_result = if nav_fields.is_empty() {
893            self.bind_expr(&cond)
894        } else {
895            let context = self.context.clone();
896            let cols: Vec<(bool, Field)> = nav_fields.iter().map(|f| (false, f.clone())).collect();
897            let result = self
898                .bind_table_to_context(cols, prefix.clone(), None, None)
899                .and_then(|_| self.bind_expr(&cond));
900            self.context = context;
901            result
902        };
903
904        // On failure, list the variables actually in scope: the classic trap is identifier case
905        // folding — `PATTERN ("A" ...)` registers `"A"` while an unquoted `A.v` in the predicate
906        // folds to `a.v` and misses it, and the generic bind error gives no way to see that.
907        let expr = bind_result.map_err(|e| {
908            crate::error::ErrorCode::BindError(format!(
909                "{}\nwhile binding the DEFINE predicate of `{symbol}`; pattern variables in scope \
910                 (case-sensitive as written): {}",
911                e.as_report(),
912                resolver.alias_names.join(", ")
913            ))
914        })?;
915        // A DEFINE predicate is evaluated per candidate row by the executor, from a serialized scalar
916        // expression; a subquery has no representation there and would otherwise be carried all the
917        // way to the plan-to-proto conversion before failing, i.e. after the statement looked fine.
918        if expr.has_subquery() {
919            return Err(crate::error::ErrorCode::NotSupported(
920                format!("a subquery in the DEFINE predicate of {symbol}"),
921                "a DEFINE predicate must be a scalar expression over the pattern variables"
922                    .to_owned(),
923            )
924            .into());
925        }
926        // The executor reads the predicate's result as a boolean, so a non-boolean DEFINE must
927        // fail here with a normal binder error — not on the compute node once data arrives. Same
928        // rule as WHERE/HAVING (untyped literals cast implicitly); Flink rejects this at
929        // validation too ("DEFINE clause must be a condition").
930        let clause = format!("the DEFINE predicate of {symbol}");
931        let expr = expr.enforce_bool_clause(&clause)?;
932
933        let (definition, slots) = {
934            let mut rewriter = DefineSlotRewriter {
935                resolver,
936                defined_var: &symbol,
937                nav_base,
938                nav_slots: &nav_slots,
939                slots: Vec::new(),
940            };
941            let definition = rewriter.rewrite_expr(expr);
942            (definition, rewriter.slots)
943        };
944        Ok(BoundSymbolDefinition {
945            symbol,
946            definition,
947            slots,
948        })
949    }
950}
951
952/// Rejects function-call modifiers on the specially-lowered `MATCH_RECOGNIZE` functions: the
953/// `MEASURES` aggregates and `FIRST`/`LAST`/`CLASSIFIER`, and the `DEFINE` navigation functions
954/// `PREV`/`NEXT`/`FIRST`/`LAST`. Both lowerings match these by name and read only the argument list,
955/// so a modifier would be dropped and the plain call evaluated, silently producing wrong results.
956/// `clause` names the clause for the error message (`MEASURES` or `DEFINE`).
957fn reject_func_modifiers(func: &Function, clause: &str) -> RwResult<()> {
958    let offending = if func.arg_list.distinct {
959        Some("DISTINCT")
960    } else if !func.arg_list.order_by.is_empty() {
961        Some("ORDER BY")
962    } else if func.arg_list.ignore_nulls {
963        Some("IGNORE NULLS")
964    } else if func.arg_list.variadic {
965        Some("VARIADIC")
966    } else if func.filter.is_some() {
967        Some("FILTER")
968    } else if func.over.is_some() {
969        Some("OVER")
970    } else if func.within_group.is_some() {
971        Some("WITHIN GROUP")
972    } else if func.scalar_as_agg {
973        Some("AGGREGATE")
974    } else {
975        None
976    };
977    if let Some(modifier) = offending {
978        bail_not_implemented!(
979            "{} on {}() in MATCH_RECOGNIZE {}",
980            modifier,
981            func.name.0[0].real_value().to_uppercase(),
982            clause
983        );
984    }
985    Ok(())
986}
987
988/// Largest bound accepted in a `{n}` / `{n,}` / `{n,m}` / `{,m}` range quantifier.
989///
990/// `Nfa::compile` expands a range quantifier eagerly: `min` mandatory copies of the inner pattern
991/// plus `max - min` optional copies, at 2 NFA states per pattern variable and 2 more per optional
992/// wrapper. Repeating a single variable therefore costs up to `4` states per repetition, so this cap
993/// bounds such a quantifier at `4 * 1000 = 4000` states, while leaving two orders of magnitude of
994/// headroom over the bounds real patterns use (single or low double digits). A larger inner pattern
995/// costs proportionally more per repetition and is bounded by [`MAX_PATTERN_NFA_STATES`] instead.
996const MAX_QUANTIFIER_BOUND: u32 = 1000;
997
998/// Largest estimated NFA state count accepted for a whole pattern.
999///
1000/// [`MAX_QUANTIFIER_BOUND`] alone does not bound the pattern: quantifiers nest, and nesting
1001/// multiplies, so `((a{900}){900})` would expand to ~810000 copies of `a` while every individual
1002/// bound is legal. This cap bounds the product. For scale: `PERMUTE` of the maximum
1003/// [`MAX_PERMUTE_VARS`] variables estimates 8642 states (8.6% of the budget) and `(a b c){1000}`
1004/// estimates 6000, so realistic patterns are far below it.
1005///
1006/// This is a *memory* bound, not a throughput one. The NFA is simulated from every candidate start
1007/// for every row, so a pattern anywhere near this many states would build successfully and still be
1008/// far too slow to be useful; the cap exists only to keep an absurd pattern from exhausting the
1009/// compute node's memory while the actor is being built.
1010const MAX_PATTERN_NFA_STATES: u64 = 100_000;
1011
1012/// Operational cap on a physical `PREV` offset in `DEFINE`.
1013///
1014/// The offset is not just a wire value: `PREV(col, k)` requires the variable to sit at least `k`
1015/// mandatory rows from the match start (see [`min_start_distances`]), so it scales the pattern's
1016/// required prefix. The cap is deliberately small — far above any observed real pattern (offsets
1017/// of 1–3), far below anything degenerate. (Physical `NEXT` is rejected in `DEFINE` outright; see
1018/// the navigation lowering.)
1019const MAX_NAV_OFFSET: usize = 100;
1020
1021/// Largest number of variables accepted in `PERMUTE(...)`.
1022///
1023/// `PERMUTE` expands to the alternation of all `n!` orderings of its variables, so the NFA grows
1024/// factorially.
1025const MAX_PERMUTE_VARS: usize = 6;
1026
1027/// Rejects a `PERMUTE` with too many variables. This is the only enforcement point: the pattern
1028/// lowering in `optimizer::plan_node::stream_match_recognize` no longer repeats the check, since every
1029/// pattern that reaches it has passed [`validate_pattern`].
1030fn reject_oversized_permute(count: usize) -> RwResult<()> {
1031    if count > MAX_PERMUTE_VARS {
1032        return Err(crate::error::ErrorCode::NotSupported(
1033            format!("PERMUTE over {count} variables (expands to {count}! orderings)"),
1034            format!("PERMUTE supports at most {MAX_PERMUTE_VARS} variables"),
1035        )
1036        .into());
1037    }
1038    Ok(())
1039}
1040
1041/// Validates that a pattern can be expanded into an NFA, at bind time.
1042///
1043/// Three hazards, all of which otherwise survive planning: an inverted range (`{5,3}`) expands to an
1044/// empty optional tail and silently degrades to `{5}`; a large bound (or a product of nested bounds)
1045/// expands to an NFA that exhausts the compute node's memory; and `PERMUTE` grows factorially in its
1046/// arity. The expansion happens in `Nfa::compile`, which runs when the actor is built — after the DDL
1047/// has been committed in meta — so the only place these can be reported to the author is here.
1048///
1049/// The arity and per-bound checks run before the whole-pattern budget so that the specific cause is
1050/// named: a `PERMUTE` of 8 variables is over the budget too, but "PERMUTE supports at most 6
1051/// variables" is the useful thing to say about it.
1052fn validate_pattern(pattern: &MatchRecognizePattern) -> RwResult<()> {
1053    check_pattern_bounds(pattern)?;
1054    let states = estimate_nfa_states(pattern);
1055    if states > MAX_PATTERN_NFA_STATES {
1056        return Err(crate::error::ErrorCode::NotSupported(
1057            format!("a MATCH_RECOGNIZE pattern that expands to about {states} NFA states"),
1058            format!(
1059                "the pattern is expanded eagerly into at most {MAX_PATTERN_NFA_STATES} states; \
1060                 reduce the quantifier bounds, especially where quantifiers are nested"
1061            ),
1062        )
1063        .into());
1064    }
1065    Ok(())
1066}
1067
1068/// Per-node bound checks: `PERMUTE` arity, `min <= max`, and each bound within
1069/// [`MAX_QUANTIFIER_BOUND`].
1070fn check_pattern_bounds(pattern: &MatchRecognizePattern) -> RwResult<()> {
1071    use risingwave_sqlparser::ast::RepetitionQuantifier as Q;
1072
1073    match pattern {
1074        MatchRecognizePattern::Symbol(_) | MatchRecognizePattern::Exclude(_) => {}
1075        MatchRecognizePattern::Permute(symbols) => {
1076            reject_oversized_permute(symbols.len())?;
1077            // PERMUTE(a, a, b) would expand duplicate orderings into redundant NFA branches and
1078            // is almost certainly an authoring mistake — reject rather than silently accept.
1079            let mut seen = std::collections::HashSet::new();
1080            for s in symbols {
1081                if let risingwave_sqlparser::ast::MatchRecognizeSymbol::Named(ident) = s {
1082                    let name = ident.real_value();
1083                    if !seen.insert(name.clone()) {
1084                        return Err(crate::error::ErrorCode::InvalidInputSyntax(format!(
1085                            "duplicate variable `{name}` in PERMUTE"
1086                        ))
1087                        .into());
1088                    }
1089                }
1090            }
1091        }
1092        MatchRecognizePattern::Concat(patterns) | MatchRecognizePattern::Alternation(patterns) => {
1093            for p in patterns {
1094                check_pattern_bounds(p)?;
1095            }
1096        }
1097        MatchRecognizePattern::Group(inner) => check_pattern_bounds(inner)?,
1098        MatchRecognizePattern::Repetition(inner, quantifier, _) => {
1099            if let Q::Range(min, max) = quantifier
1100                && min > max
1101            {
1102                return Err(crate::error::ErrorCode::NotSupported(
1103                    format!("a range quantifier with a lower bound above its upper bound ({{{min},{max}}})"),
1104                    format!("use {{{max},{min}}} if the bounds were swapped, or {{{min}}} for exactly {min} repetitions"),
1105                )
1106                .into());
1107            }
1108            let bounds = match quantifier {
1109                Q::ZeroOrMore | Q::OneOrMore | Q::AtMostOne => vec![],
1110                Q::Exactly(n) | Q::AtLeast(n) => vec![*n],
1111                Q::AtMost(m) => vec![*m],
1112                Q::Range(n, m) => vec![*n, *m],
1113            };
1114            for b in bounds {
1115                if b > MAX_QUANTIFIER_BOUND {
1116                    return Err(crate::error::ErrorCode::NotSupported(
1117                        format!("a range quantifier bound of {b}"),
1118                        format!(
1119                            "a bound is expanded eagerly into up to 4 NFA states per repetition of \
1120                             a single variable (more for a larger inner pattern), so it may be at \
1121                             most {MAX_QUANTIFIER_BOUND}"
1122                        ),
1123                    )
1124                    .into());
1125                }
1126            }
1127            check_pattern_bounds(inner)?;
1128        }
1129    }
1130    Ok(())
1131}
1132
1133/// Upper bound on the number of NFA states `Nfa::compile` will allocate for `pattern`.
1134///
1135/// Mirrors the construction in `nfa.rs` (`Nfa::build`): a variable is 2 states; an alternation adds a
1136/// start and an accept state; `*`, `?` and each optional copy of a range add 2; `+` builds the inner
1137/// pattern twice; `PERMUTE` of `n` variables becomes an alternation of `n!` concatenations. Saturating
1138/// throughout, so an over-large pattern reports a saturated estimate rather than wrapping.
1139///
1140/// **This is a model of code in another crate**, so it cannot be checked by a test: `risingwave_stream`
1141/// depends on `risingwave_frontend`'s protos, not the reverse, and nothing can observe both. If the
1142/// state count of any construct in `nfa.rs` changes, this must change with it, or the guard above
1143/// silently becomes an under-estimate. The construction sites in `nfa.rs` carry a comment pointing
1144/// back here.
1145fn estimate_nfa_states(pattern: &MatchRecognizePattern) -> u64 {
1146    use risingwave_sqlparser::ast::RepetitionQuantifier as Q;
1147
1148    match pattern {
1149        // Anchors and exclusions are rejected when the pattern is lowered; 2 is the cost of the
1150        // variable form.
1151        MatchRecognizePattern::Symbol(_) | MatchRecognizePattern::Exclude(_) => 2,
1152        MatchRecognizePattern::Permute(symbols) => {
1153            let n = symbols.len() as u64;
1154            let orderings = (1..=n)
1155                .try_fold(1u64, |acc, i| acc.checked_mul(i))
1156                .unwrap_or(u64::MAX);
1157            orderings
1158                .saturating_mul(n.saturating_mul(2))
1159                .saturating_add(2)
1160        }
1161        MatchRecognizePattern::Concat(patterns) => patterns
1162            .iter()
1163            .map(estimate_nfa_states)
1164            .fold(0u64, u64::saturating_add)
1165            .max(1),
1166        MatchRecognizePattern::Alternation(patterns) => patterns
1167            .iter()
1168            .map(estimate_nfa_states)
1169            .fold(2u64, u64::saturating_add),
1170        MatchRecognizePattern::Group(inner) => estimate_nfa_states(inner),
1171        MatchRecognizePattern::Repetition(inner, quantifier, _) => {
1172            let inner = estimate_nfa_states(inner);
1173            match quantifier {
1174                Q::ZeroOrMore | Q::AtMostOne => inner.saturating_add(2),
1175                Q::OneOrMore => inner.saturating_mul(2).saturating_add(2),
1176                // `min` mandatory copies, then an unbounded `*` tail.
1177                Q::AtLeast(min) => inner
1178                    .saturating_mul(*min as u64)
1179                    .saturating_add(inner)
1180                    .saturating_add(2),
1181                // `min` mandatory copies, then `max - min` optional (`?`) copies.
1182                Q::Exactly(n) => inner.saturating_mul(*n as u64).max(1),
1183                Q::AtMost(max) => inner.saturating_add(2).saturating_mul(*max as u64).max(1),
1184                Q::Range(min, max) => inner
1185                    .saturating_mul(*min as u64)
1186                    .saturating_add(
1187                        inner
1188                            .saturating_add(2)
1189                            .saturating_mul(max.saturating_sub(*min) as u64),
1190                    )
1191                    .max(1),
1192            }
1193        }
1194    }
1195}
1196
1197/// Name of the synthetic relation that backs a `DEFINE`'s navigation placeholders.
1198const NAV_TABLE: &str = "__mr_nav";
1199
1200/// Extracts row-pattern navigation functions (`PREV`/`NEXT`/`FIRST`/`LAST`) from a `DEFINE`
1201/// predicate AST, replacing each with a synthetic placeholder column and recording the corresponding
1202/// [`DefineSlot`]. Functions are handled because they do not bind as ordinary scalar functions;
1203/// plain variable-qualified columns are left to bind normally and are mapped later.
1204struct NavExtractor<'a> {
1205    input_fields: &'a [Field],
1206    resolver: &'a VarResolver<'a>,
1207    /// The symbol whose `DEFINE` predicate is being lowered — the only variable qualifier a
1208    /// physical `PREV` may carry (see [`NavExtractor::physical_col`]).
1209    symbol: &'a str,
1210    /// Per-DEFINE prefix for the synthetic placeholder column names (kept unique across DEFINE items).
1211    prefix: &'a str,
1212    nav_slots: Vec<DefineSlot>,
1213    nav_fields: Vec<Field>,
1214}
1215
1216impl NavExtractor<'_> {
1217    fn rewrite(&mut self, node: &mut AstExpr) -> RwResult<()> {
1218        if let AstExpr::Function(func) = node
1219            && func.name.0.len() == 1
1220            && matches!(
1221                func.name.0[0].real_value().to_ascii_lowercase().as_str(),
1222                "prev" | "next" | "first" | "last"
1223            )
1224        {
1225            let k = self.nav_slots.len();
1226            let slot = self.nav_slot(func)?;
1227            let data_type = self.input_fields[slot.col_idx].data_type();
1228            let col_name = format!("{}_{k}", self.prefix);
1229            self.nav_fields
1230                .push(Field::with_name(data_type, col_name.clone()));
1231            *node = AstExpr::Identifier(Ident::new_unchecked(col_name));
1232            self.nav_slots.push(slot);
1233            return Ok(());
1234        }
1235        // Every variant that carries sub-expressions is traversed. The match is deliberately
1236        // exhaustive (no `_` arm): a navigation call the traversal fails to reach is never extracted,
1237        // and binding then reports the misleading "function prev(integer) does not exist" instead of
1238        // anything about navigation. Making the compiler flag new `Expr` variants keeps that from
1239        // silently regressing.
1240        match node {
1241            AstExpr::BinaryOp { left, right, .. }
1242            | AstExpr::IsDistinctFrom(left, right)
1243            | AstExpr::IsNotDistinctFrom(left, right) => {
1244                self.rewrite(left)?;
1245                self.rewrite(right)?;
1246            }
1247            AstExpr::UnaryOp { expr, .. }
1248            | AstExpr::Nested(expr)
1249            | AstExpr::IsNull(expr)
1250            | AstExpr::IsNotNull(expr)
1251            | AstExpr::IsTrue(expr)
1252            | AstExpr::IsNotTrue(expr)
1253            | AstExpr::IsFalse(expr)
1254            | AstExpr::IsNotFalse(expr)
1255            | AstExpr::IsUnknown(expr)
1256            | AstExpr::IsNotUnknown(expr)
1257            | AstExpr::IsJson { expr, .. }
1258            | AstExpr::FieldIdentifier(expr, _)
1259            | AstExpr::SomeOp(expr)
1260            | AstExpr::AllOp(expr)
1261            | AstExpr::Extract { expr, .. }
1262            | AstExpr::Collate { expr, .. }
1263            | AstExpr::Cast { expr, .. }
1264            | AstExpr::TryCast { expr, .. } => self.rewrite(expr)?,
1265            AstExpr::Between {
1266                expr, low, high, ..
1267            } => {
1268                self.rewrite(expr)?;
1269                self.rewrite(low)?;
1270                self.rewrite(high)?;
1271            }
1272            AstExpr::InList { expr, list, .. } => {
1273                self.rewrite(expr)?;
1274                for e in list {
1275                    self.rewrite(e)?;
1276                }
1277            }
1278            AstExpr::Like { expr, pattern, .. }
1279            | AstExpr::ILike { expr, pattern, .. }
1280            | AstExpr::SimilarTo { expr, pattern, .. } => {
1281                self.rewrite(expr)?;
1282                self.rewrite(pattern)?;
1283            }
1284            AstExpr::AtTimeZone {
1285                timestamp,
1286                time_zone,
1287            } => {
1288                self.rewrite(timestamp)?;
1289                self.rewrite(time_zone)?;
1290            }
1291            AstExpr::Substring {
1292                expr,
1293                substring_from,
1294                substring_for,
1295            } => {
1296                self.rewrite(expr)?;
1297                for e in substring_from.iter_mut().chain(substring_for.iter_mut()) {
1298                    self.rewrite(e)?;
1299                }
1300            }
1301            AstExpr::Position { substring, string } => {
1302                self.rewrite(substring)?;
1303                self.rewrite(string)?;
1304            }
1305            AstExpr::Overlay {
1306                expr,
1307                new_substring,
1308                start,
1309                count,
1310            } => {
1311                self.rewrite(expr)?;
1312                self.rewrite(new_substring)?;
1313                self.rewrite(start)?;
1314                if let Some(e) = count {
1315                    self.rewrite(e)?;
1316                }
1317            }
1318            AstExpr::Trim {
1319                expr, trim_what, ..
1320            } => {
1321                self.rewrite(expr)?;
1322                if let Some(e) = trim_what {
1323                    self.rewrite(e)?;
1324                }
1325            }
1326            AstExpr::Case {
1327                operand,
1328                conditions,
1329                results,
1330                else_result,
1331            } => {
1332                for e in operand.iter_mut().chain(else_result.iter_mut()) {
1333                    self.rewrite(e)?;
1334                }
1335                for e in conditions.iter_mut().chain(results.iter_mut()) {
1336                    self.rewrite(e)?;
1337                }
1338            }
1339            AstExpr::GroupingSets(sets) | AstExpr::Cube(sets) | AstExpr::Rollup(sets) => {
1340                for set in sets {
1341                    for e in set {
1342                        self.rewrite(e)?;
1343                    }
1344                }
1345            }
1346            AstExpr::Row(exprs) => {
1347                for e in exprs {
1348                    self.rewrite(e)?;
1349                }
1350            }
1351            AstExpr::Array(array) => {
1352                for e in &mut array.elem {
1353                    self.rewrite(e)?;
1354                }
1355            }
1356            AstExpr::Index { obj, index } => {
1357                self.rewrite(obj)?;
1358                self.rewrite(index)?;
1359            }
1360            AstExpr::ArrayRangeIndex { obj, start, end } => {
1361                self.rewrite(obj)?;
1362                for e in start.iter_mut().chain(end.iter_mut()) {
1363                    self.rewrite(e)?;
1364                }
1365            }
1366            AstExpr::Map { entries } => {
1367                for (k, v) in entries {
1368                    self.rewrite(k)?;
1369                    self.rewrite(v)?;
1370                }
1371            }
1372            // A non-navigation call: only its arguments can contain navigation. The modifiers
1373            // (`FILTER`, `OVER`, `WITHIN GROUP`, the aggregate `ORDER BY`) are not traversed — they
1374            // only occur on aggregate and window calls, neither of which is supported in a DEFINE
1375            // predicate at all.
1376            AstExpr::Function(func) => {
1377                for arg in &mut func.arg_list.args {
1378                    match arg {
1379                        FunctionArg::Unnamed(FunctionArgExpr::Expr(e))
1380                        | FunctionArg::Named {
1381                            arg: FunctionArgExpr::Expr(e),
1382                            ..
1383                        } => self.rewrite(e)?,
1384                        _ => {}
1385                    }
1386                }
1387            }
1388            // Leaves: nothing to traverse.
1389            AstExpr::Identifier(_)
1390            | AstExpr::CompoundIdentifier(_)
1391            | AstExpr::Value(_)
1392            | AstExpr::Parameter { .. }
1393            | AstExpr::TypedString { .. } => {}
1394            // `IN (SELECT ...)`: the left-hand operand is an ordinary expression outside the
1395            // subquery, so it is traversed; only the subquery itself is not.
1396            AstExpr::InSubquery { expr, .. } => self.rewrite(expr)?,
1397            // The remaining subquery-bearing forms carry nothing but a `Query`. Row-pattern
1398            // navigation is defined over the rows of the match, which a subquery cannot see, so
1399            // nothing inside one is extracted; a navigation call there is reported by ordinary
1400            // binding.
1401            AstExpr::Exists(_) | AstExpr::Subquery(_) | AstExpr::ArraySubquery(_) => {}
1402            // A lambda body is evaluated per element by the higher-order function that receives it,
1403            // not once per candidate row, so a navigation placeholder cannot be lifted out of it.
1404            AstExpr::LambdaFunction { .. } => {}
1405        }
1406        Ok(())
1407    }
1408
1409    /// Builds the [`DefineSlot`] for a navigation function call.
1410    fn nav_slot(&self, func: &Function) -> RwResult<DefineSlot> {
1411        let name = func.name.0[0].real_value().to_ascii_lowercase();
1412        // Only the name and the argument list are read below, so any modifier would be dropped.
1413        reject_func_modifiers(func, "DEFINE")?;
1414        let args = &func.arg_list.args;
1415        let Some(FunctionArg::Unnamed(FunctionArgExpr::Expr(inner))) = args.first() else {
1416            bail_not_implemented!(
1417                "{}() argument must be a column in DEFINE",
1418                name.to_uppercase()
1419            );
1420        };
1421        match name.as_str() {
1422            "prev" | "next" => {
1423                // Not `bail_not_implemented!`: this is not a feature gap, the call is simply wrong.
1424                if args.len() > 2 {
1425                    return Err(crate::error::ErrorCode::NotSupported(
1426                        format!(
1427                            "{}() with {} arguments in DEFINE",
1428                            name.to_uppercase(),
1429                            args.len()
1430                        ),
1431                        format!(
1432                            "{}() takes a column and an optional positive integer offset",
1433                            name.to_uppercase()
1434                        ),
1435                    )
1436                    .into());
1437                }
1438                // Physical NEXT in DEFINE is not in the v1 subset. A row's verdict would read rows
1439                // after it, so it is only final once that lookahead is watermark-safe — and a single
1440                // global "decision horizon" (defer everything by the max offset) can permanently
1441                // starve an idle partition whose match is in fact already decidable (the lookahead
1442                // row is inside the match). Correct support needs per-path decidability: an
1443                // evaluation that actually reads past the safe prefix must be a wait for exactly
1444                // that candidate, not a global delay and not a NULL verdict. Until that lands,
1445                // reject rather than expose either wrong behaviour.
1446                if name != "prev" {
1447                    bail_not_implemented!(
1448                        "physical NEXT() in MATCH_RECOGNIZE DEFINE (a row's verdict would depend \
1449                         on rows after it; per-candidate decidability is not implemented yet)"
1450                    );
1451                }
1452                let col_idx = self.physical_col(inner)?;
1453                let offset = match args.get(1) {
1454                    Some(arg) => self.parse_offset(arg, &name)?,
1455                    None => 1,
1456                };
1457                Ok(DefineSlot {
1458                    kind: DefineSlotKind::Prev,
1459                    vars: vec![],
1460                    col_idx,
1461                    offset,
1462                })
1463            }
1464            _ => {
1465                if args.len() != 1 {
1466                    bail_not_implemented!("{}() with an offset in DEFINE", name.to_uppercase());
1467                }
1468                let (vars, col_idx) = self.var_col(inner)?;
1469                let kind = if name == "first" {
1470                    DefineSlotKind::RunningFirst
1471                } else {
1472                    DefineSlotKind::RunningLast
1473                };
1474                Ok(DefineSlot {
1475                    kind,
1476                    vars,
1477                    col_idx,
1478                    offset: 0,
1479                })
1480            }
1481        }
1482    }
1483
1484    /// Resolves a physical-navigation argument (`col` or `var.col`) to its input column index.
1485    ///
1486    /// A variable qualifier is accepted only when it is the symbol being defined: under the
1487    /// standard's running semantics `PREV(B.col)` inside `DEFINE B` reads from the row before the
1488    /// current candidate — exactly the physical previous row this engine navigates to. A qualifier
1489    /// naming ANOTHER variable anchors the read to that variable's last mapped row instead
1490    /// (`PREV(A.col)` ≡ `PREV(LAST(A.col, 0), 1)`), which is different semantics this engine does
1491    /// not implement — silently treating it as the physical previous row would answer a question
1492    /// the author did not ask. An undeclared qualifier is plain wrong SQL.
1493    fn physical_col(&self, expr: &AstExpr) -> RwResult<usize> {
1494        let col = match expr {
1495            AstExpr::Identifier(c) => c.real_value(),
1496            AstExpr::CompoundIdentifier(parts) if parts.len() == 2 => {
1497                let var = parts[0].real_value();
1498                if !self.resolver.alias_names.iter().any(|n| n == &var) {
1499                    return Err(crate::error::ErrorCode::InvalidInputSyntax(format!(
1500                        "PREV/NEXT in DEFINE references unknown pattern variable `{var}`"
1501                    ))
1502                    .into());
1503                }
1504                if var != self.symbol {
1505                    bail_not_implemented!(
1506                        "PREV/NEXT anchored to another pattern variable's rows in DEFINE \
1507                         (`{}` inside the definition of `{}`); qualify with the symbol being \
1508                         defined, or leave the column unqualified, for physical navigation from \
1509                         the current row",
1510                        var,
1511                        self.symbol
1512                    );
1513                }
1514                parts[1].real_value()
1515            }
1516            _ => bail_not_implemented!("PREV/NEXT argument must be a column reference in DEFINE"),
1517        };
1518        self.col_idx(&col)
1519    }
1520
1521    /// Resolves a logical-navigation argument (`var.col`) to its variable(s) and input column index.
1522    fn var_col(&self, expr: &AstExpr) -> RwResult<(Vec<String>, usize)> {
1523        let AstExpr::CompoundIdentifier(parts) = expr else {
1524            bail_not_implemented!(
1525                "FIRST/LAST argument must be a pattern-variable column in DEFINE"
1526            );
1527        };
1528        if parts.len() != 2 {
1529            bail_not_implemented!(
1530                "FIRST/LAST argument must be a pattern-variable column in DEFINE"
1531            );
1532        }
1533        let var = parts[0].real_value();
1534        if !self.resolver.alias_names.iter().any(|n| n == &var) {
1535            bail_not_implemented!(
1536                "FIRST/LAST references unknown pattern variable {} in DEFINE",
1537                var
1538            );
1539        }
1540        Ok((
1541            self.resolver.members_of(&var),
1542            self.col_idx(&parts[1].real_value())?,
1543        ))
1544    }
1545
1546    fn col_idx(&self, name: &str) -> RwResult<usize> {
1547        // Zero, one and many matches are three different answers: with duplicate input column
1548        // names (`SELECT v AS x, v + 100 AS x ...`) silently taking the first physical field
1549        // would bind the navigation to an arbitrary column and change match results.
1550        let mut hits = self
1551            .input_fields
1552            .iter()
1553            .enumerate()
1554            .filter(|(_, f)| f.name == name);
1555        match (hits.next(), hits.next()) {
1556            (Some((i, _)), None) => Ok(i),
1557            (None, _) => bail_not_implemented!("navigation over unknown column {} in DEFINE", name),
1558            (Some(_), Some(_)) => Err(crate::error::ErrorCode::BindError(format!(
1559                "column reference \"{name}\" in MATCH_RECOGNIZE navigation is ambiguous: the \
1560                 input has more than one column with that name"
1561            ))
1562            .into()),
1563        }
1564    }
1565
1566    fn parse_offset(&self, arg: &FunctionArg, name: &str) -> RwResult<usize> {
1567        let FunctionArg::Unnamed(FunctionArgExpr::Expr(AstExpr::Value(AstValue::Number(s)))) = arg
1568        else {
1569            return Err(Self::offset_not_a_positive_literal(name));
1570        };
1571        // A `Number` token can be any numeric literal, so distinguish "not a non-negative integer at
1572        // all" from "an integer that is simply too large": the latter must report the cap, not claim
1573        // the literal was not an integer.
1574        let is_integer_literal = !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
1575        match s.parse::<u64>() {
1576            // The offset is an operational knob, not just a wire-format concern: a `PREV` offset
1577            // demands that many mandatory rows before the variable in the pattern, so the cap is
1578            // deliberately small.
1579            Ok(n) if n > MAX_NAV_OFFSET as u64 => Err(Self::offset_above_cap(name, s)),
1580            // The offset must be positive: `PREV(col, 0)` / `NEXT(col, 0)` would resolve to the
1581            // current row, which is surprising for physical navigation and not what these mean.
1582            Ok(0) => Err(Self::offset_not_a_positive_literal(name)),
1583            Ok(n) => Ok(n as usize),
1584            // Out of `u64` range, but still a decimal integer literal: over the cap, by a lot.
1585            Err(_) if is_integer_literal => Err(Self::offset_above_cap(name, s)),
1586            Err(_) => Err(Self::offset_not_a_positive_literal(name)),
1587        }
1588    }
1589
1590    fn offset_above_cap(name: &str, literal: &str) -> crate::error::RwError {
1591        crate::error::ErrorCode::NotSupported(
1592            format!("{}() offset of {}", name.to_uppercase(), literal),
1593            format!(
1594                "a PREV() offset requires that many mandatory pattern rows before the variable, \
1595                 so the offset may be at most {MAX_NAV_OFFSET}"
1596            ),
1597        )
1598        .into()
1599    }
1600
1601    fn offset_not_a_positive_literal(name: &str) -> crate::error::RwError {
1602        crate::error::ErrorCode::NotSupported(
1603            format!(
1604                "a non-literal or non-positive {}() offset",
1605                name.to_uppercase()
1606            ),
1607            format!(
1608                "{}() offset must be a positive integer literal (>= 1)",
1609                name.to_uppercase()
1610            ),
1611        )
1612        .into()
1613    }
1614}
1615
1616/// Maps each `InputRef` in a bound `DEFINE` predicate to a [`DefineSlot`]: navigation placeholders
1617/// (index `>= nav_base`) to their pre-resolved slot; variable-qualified columns to a self slot (the
1618/// defined variable, or an unqualified/raw-input reference) or a running slot (another variable).
1619struct DefineSlotRewriter<'a> {
1620    resolver: &'a VarResolver<'a>,
1621    defined_var: &'a str,
1622    nav_base: usize,
1623    nav_slots: &'a [DefineSlot],
1624    slots: Vec<DefineSlot>,
1625}
1626
1627impl ExprRewriter for DefineSlotRewriter<'_> {
1628    fn rewrite_input_ref(&mut self, input_ref: InputRef) -> ExprImpl {
1629        let index = input_ref.index();
1630        let data_type = input_ref.data_type;
1631        let slot = if index >= self.nav_base {
1632            self.nav_slots[index - self.nav_base].clone()
1633        } else {
1634            let n = self.resolver.input_col_num;
1635            let col_idx = index % n;
1636            let block = index / n;
1637            let self_slot = DefineSlot {
1638                kind: DefineSlotKind::SelfCol,
1639                vars: vec![],
1640                col_idx,
1641                offset: 0,
1642            };
1643            if block == 0 {
1644                self_slot
1645            } else {
1646                let name = &self.resolver.alias_names[block - 1];
1647                if name == self.defined_var {
1648                    self_slot
1649                } else {
1650                    DefineSlot {
1651                        kind: DefineSlotKind::RunningLast,
1652                        vars: self.resolver.members_of(name),
1653                        col_idx,
1654                        offset: 0,
1655                    }
1656                }
1657            }
1658        };
1659        let idx = self
1660            .slots
1661            .iter()
1662            .position(|s| *s == slot)
1663            .unwrap_or_else(|| {
1664                self.slots.push(slot);
1665                self.slots.len() - 1
1666            });
1667        InputRef::new(idx, data_type).into()
1668    }
1669}
1670
1671/// Collect the distinct pattern-variable names appearing in a pattern, unioned with the `DEFINE`
1672/// symbols. The union is defensive: `bind_match_recognize` rejects any `DEFINE` symbol absent from
1673/// the pattern before this runs, so the two sets are equal there.
1674fn collect_pattern_variables(
1675    pattern: &MatchRecognizePattern,
1676    symbols: &[SymbolDefinition],
1677) -> Vec<String> {
1678    let mut vars: BTreeSet<String> = BTreeSet::new();
1679    collect_from_pattern(pattern, &mut vars);
1680    for s in symbols {
1681        vars.insert(s.symbol.real_value());
1682    }
1683    vars.into_iter().collect()
1684}
1685
1686/// Per pattern variable: the minimum number of rows a match has necessarily consumed before a row
1687/// can be labeled with that variable — its minimum distance from the match start.
1688///
1689/// This is what makes a physical `PREV(col, k)` in the variable's `DEFINE` safe without any
1690/// retention of pre-match rows: if the variable can only ever sit at distance `>= k` from the
1691/// match start, every `PREV` read lands inside the match span, and rows of a live match are never
1692/// evicted (the eviction walker keeps everything from the first live start onward). A read that
1693/// could reach *before* the match start would observe a retained row before eviction and `NULL`
1694/// after it — the same row flipping its verdict on timing — so those shapes are rejected at bind
1695/// time (see the check in [`Binder::bind_match_recognize`]).
1696///
1697/// The walk is exact for the supported constructs and conservative by construction elsewhere:
1698/// - concatenation shifts a variable's distance by the *minimum* length of everything before it
1699///   (a zero-minimum quantifier prefix contributes 0 — `(a* b)` leaves `b` at distance 0);
1700/// - alternation takes the minimum across branches;
1701/// - a quantified sub-pattern keeps its inner distances unshifted (the first iteration starts at
1702///   the node's start; later iterations only sit further from the match start);
1703/// - `PERMUTE` puts every element at distance 0 (any ordering may put it first).
1704///
1705/// A variable occurring several times keeps the smallest distance of any occurrence.
1706fn min_start_distances(pattern: &MatchRecognizePattern) -> HashMap<String, u64> {
1707    fn insert_min(map: &mut HashMap<String, u64>, var: String, dist: u64) {
1708        map.entry(var)
1709            .and_modify(|d| *d = (*d).min(dist))
1710            .or_insert(dist);
1711    }
1712
1713    /// Returns the minimum number of rows `pattern` consumes, recording each contained variable's
1714    /// minimum start distance *relative to this node's start* into `map`.
1715    fn walk(pattern: &MatchRecognizePattern, map: &mut HashMap<String, u64>) -> u64 {
1716        use risingwave_sqlparser::ast::RepetitionQuantifier as Q;
1717        match pattern {
1718            MatchRecognizePattern::Symbol(MatchRecognizeSymbol::Named(ident))
1719            | MatchRecognizePattern::Exclude(MatchRecognizeSymbol::Named(ident)) => {
1720                insert_min(map, ident.real_value(), 0);
1721                1
1722            }
1723            // Unnamed symbols (anchors) are not in the v1 subset; count them as consuming no rows,
1724            // which can only *shrink* distances — conservative for this check.
1725            MatchRecognizePattern::Symbol(_) | MatchRecognizePattern::Exclude(_) => 0,
1726            MatchRecognizePattern::Permute(symbols) => {
1727                for s in symbols {
1728                    if let MatchRecognizeSymbol::Named(ident) = s {
1729                        // Any element can be ordered first.
1730                        insert_min(map, ident.real_value(), 0);
1731                    }
1732                }
1733                symbols.len() as u64
1734            }
1735            MatchRecognizePattern::Concat(patterns) => {
1736                let mut prefix = 0u64;
1737                for p in patterns {
1738                    let mut inner = HashMap::new();
1739                    let len = walk(p, &mut inner);
1740                    for (v, d) in inner {
1741                        insert_min(map, v, d.saturating_add(prefix));
1742                    }
1743                    prefix = prefix.saturating_add(len);
1744                }
1745                prefix
1746            }
1747            MatchRecognizePattern::Alternation(patterns) => {
1748                let mut min_len = u64::MAX;
1749                for p in patterns {
1750                    min_len = min_len.min(walk(p, map));
1751                }
1752                if patterns.is_empty() { 0 } else { min_len }
1753            }
1754            MatchRecognizePattern::Group(inner) => walk(inner, map),
1755            MatchRecognizePattern::Repetition(inner, quantifier, _) => {
1756                let len = walk(inner, map);
1757                let min_reps: u64 = match quantifier {
1758                    Q::ZeroOrMore | Q::AtMostOne | Q::AtMost(_) => 0,
1759                    Q::OneOrMore => 1,
1760                    Q::Exactly(n) | Q::AtLeast(n) | Q::Range(n, _) => u64::from(*n),
1761                };
1762                len.saturating_mul(min_reps)
1763            }
1764        }
1765    }
1766
1767    let mut map = HashMap::new();
1768    walk(pattern, &mut map);
1769    map
1770}
1771
1772fn collect_from_pattern(pattern: &MatchRecognizePattern, out: &mut BTreeSet<String>) {
1773    match pattern {
1774        MatchRecognizePattern::Symbol(MatchRecognizeSymbol::Named(ident))
1775        | MatchRecognizePattern::Exclude(MatchRecognizeSymbol::Named(ident)) => {
1776            out.insert(ident.real_value());
1777        }
1778        MatchRecognizePattern::Symbol(_) | MatchRecognizePattern::Exclude(_) => {}
1779        MatchRecognizePattern::Permute(symbols) => {
1780            for s in symbols {
1781                if let MatchRecognizeSymbol::Named(ident) = s {
1782                    out.insert(ident.real_value());
1783                }
1784            }
1785        }
1786        MatchRecognizePattern::Concat(patterns) | MatchRecognizePattern::Alternation(patterns) => {
1787            for p in patterns {
1788                collect_from_pattern(p, out);
1789            }
1790        }
1791        MatchRecognizePattern::Group(inner) => collect_from_pattern(inner, out),
1792        MatchRecognizePattern::Repetition(inner, _, _) => collect_from_pattern(inner, out),
1793    }
1794}
1795
1796/// Decodes a measure `InputRef` back to the pattern variable(s) and input column it references.
1797/// Pattern variables and `SUBSET` names are each registered as an alias block of width
1798/// `input_col_num` after the input columns, in `alias_names` order; so block 0 is the raw input (an
1799/// unqualified reference, unsupported) and block `k + 1` is `alias_names[k]`. A `SUBSET` name
1800/// resolves to its member variables; a plain variable resolves to itself.
1801struct VarResolver<'a> {
1802    input_col_num: usize,
1803    alias_names: &'a [String],
1804    subset_defs: &'a [(String, Vec<String>)],
1805}
1806
1807impl VarResolver<'_> {
1808    fn resolve(&self, index: usize) -> RwResult<(Vec<String>, usize)> {
1809        if index / self.input_col_num == 0 {
1810            bail_not_implemented!(
1811                "unqualified or non-pattern-variable column reference in MATCH_RECOGNIZE MEASURES"
1812            );
1813        }
1814        Ok(self.resolve_unchecked(index))
1815    }
1816
1817    /// As [`VarResolver::resolve`] but assumes a pattern-variable-qualified reference (block >= 1),
1818    /// which [`InputRefBlockCheck`] guarantees before lowering.
1819    fn resolve_unchecked(&self, index: usize) -> (Vec<String>, usize) {
1820        let block = index / self.input_col_num;
1821        let name = self
1822            .alias_names
1823            .get(block - 1)
1824            .expect("alias block within range of registered variables/subsets");
1825        let vars = self.members_of(name);
1826        (vars, index % self.input_col_num)
1827    }
1828
1829    /// The variables a name resolves to: a `SUBSET`'s members, or the variable itself.
1830    fn members_of(&self, name: &str) -> Vec<String> {
1831        self.subset_defs
1832            .iter()
1833            .find(|(n, _)| n == name)
1834            .map_or_else(|| vec![name.to_owned()], |(_, members)| members.clone())
1835    }
1836}
1837
1838/// Checks that every measure `InputRef` is pattern-variable-qualified (alias block >= 1). A
1839/// reference into block 0 is the raw input — an unqualified or table-qualified column with no
1840/// pattern-variable navigation meaning.
1841struct InputRefBlockCheck {
1842    input_col_num: usize,
1843    /// First index past the variable/subset alias blocks; anything at or above it is internal
1844    /// binder scaffolding (`__mr_nav` placeholders) that user SQL must not address.
1845    nav_floor: usize,
1846    unqualified: bool,
1847    internal: bool,
1848}
1849
1850impl ExprVisitor for InputRefBlockCheck {
1851    fn visit_input_ref(&mut self, input_ref: &InputRef) {
1852        if input_ref.index() < self.input_col_num {
1853            self.unqualified = true;
1854        }
1855        if input_ref.index() >= self.nav_floor {
1856            self.internal = true;
1857        }
1858    }
1859}
1860
1861/// Rewrites each pattern-variable-qualified `InputRef` in a measure expression to an `InputRef` into
1862/// the synthetic per-match row, recording a deduplicated `LAST(var.col)` slot for it.
1863struct SlotLoweringRewriter<'a, 'b> {
1864    resolver: &'a VarResolver<'b>,
1865    slots: Vec<MeasureSlot>,
1866}
1867
1868impl ExprRewriter for SlotLoweringRewriter<'_, '_> {
1869    fn rewrite_input_ref(&mut self, input_ref: InputRef) -> ExprImpl {
1870        let (vars, col_idx) = self.resolver.resolve_unchecked(input_ref.index());
1871        let data_type = input_ref.data_type;
1872        let slot_idx = self
1873            .slots
1874            .iter()
1875            .position(|s| s.kind == MeasureSlotKind::Last && s.vars == vars && s.col_idx == col_idx)
1876            .unwrap_or_else(|| {
1877                self.slots.push(MeasureSlot {
1878                    kind: MeasureSlotKind::Last,
1879                    vars,
1880                    col_idx,
1881                    data_type: data_type.clone(),
1882                    agg: None,
1883                });
1884                self.slots.len() - 1
1885            });
1886        InputRef::new(slot_idx, data_type).into()
1887    }
1888}
1889
1890/// Lower a `WITHIN` bound into the two expressions the executor consumes: the span predicate over a
1891/// synthetic `[last_order_key, first_order_key]` row, and the per-row deadline over a synthetic
1892/// `[first_order_key]` row.
1893///
1894/// Extracted so their relationship is testable. The executor treats the deadline as interchangeable
1895/// with the right-hand side of the span predicate — it is what lets a hot-path span check reuse the
1896/// deadline already cached per row instead of evaluating an expression — and that interchangeability
1897/// holds only because BOTH are built here from one `first + bound`, and because the check below
1898/// forces that sum to keep the order key's type. The
1899/// `within_predicate_right_hand_side_is_the_deadline` test pins the first property; the
1900/// `within_bound_that_promotes_the_order_key_type_is_rejected` test pins the second.
1901///
1902/// Lowering the predicate as `(last - first) <= bound` looks equivalent and is not, for
1903/// calendar-varying intervals: timestamp subtraction yields a months-free interval compared under
1904/// 30-day normalization, while `first + INTERVAL '1 month'` is calendar addition. For starts in
1905/// short months the deadline would then close BEFORE the span window, prematurely finalizing and
1906/// evicting live partials.
1907///
1908/// The sum can still leave the order key's range at runtime (a `smallint` key at `32766` with
1909/// `WITHIN 2::smallint`). That is not a bind-time concern: every representable order key lies inside
1910/// such a span, so the executor reads an out-of-range sum as a window that never closes
1911/// (`Deadline::Never` in the stream crate) rather than as a NULL that the span check would reject.
1912fn lower_within(ts_type: DataType, bound: ExprImpl) -> crate::error::Result<(ExprImpl, ExprImpl)> {
1913    let last = ExprImpl::from(InputRef::new(0, ts_type.clone()));
1914    let first = ExprImpl::from(InputRef::new(1, ts_type.clone()));
1915    let first_plus_bound = ExprImpl::from(FunctionCall::new(
1916        ExprType::Add,
1917        vec![first, bound.clone()],
1918    )?);
1919    // `Add` goes through generic type inference, so the sum can be WIDER than the order key: an
1920    // `int2` key with a bare `2` (which binds as `int4`) yields an `int4` deadline, and a `date` key
1921    // with an interval bound yields `timestamp`. The executor compares the cached deadline against
1922    // the order key and against the watermark directly, and `ScalarRefImpl::default_cmp` panics on
1923    // mismatched variants — an actor panic as soon as rows flow, and a crash loop once recovery
1924    // replays the same rows. The span predicate alone would survive this (its `FunctionCall::new`
1925    // inserts an implicit cast on `last`), but the deadline consumers have no such protection.
1926    //
1927    // Rejected rather than cast back down: truncating `timestamp -> date` would close the window
1928    // early and prematurely finalize and evict live partials — the same calendar-correctness trap
1929    // described above.
1930    let sum_type = first_plus_bound.return_type();
1931    if sum_type != ts_type {
1932        return Err(crate::error::ErrorCode::NotSupported(
1933            format!(
1934                "a MATCH_RECOGNIZE WITHIN bound whose addition widens the ORDER BY type \
1935                 ({ts_type} + bound yields {sum_type})"
1936            ),
1937            // Deliberately does not offer `WITHIN <bound>::{ts_type}` unconditionally: for the
1938            // motivating `date` + interval case there is no such cast (`interval::date` is not a
1939            // valid cast), and suggesting it sends the reader down a dead end. Widening within one
1940            // numeric family is castable; crossing families is not.
1941            if sum_type.is_numeric() && ts_type.is_numeric() {
1942                format!(
1943                    "the bound must keep the ORDER BY column's type, since it is also used as a \
1944                     per-row deadline compared against that column and the watermark — cast the \
1945                     bound, e.g. `WITHIN <bound>::{ts_type}`"
1946                )
1947            } else {
1948                format!(
1949                    "the bound must keep the ORDER BY column's type, since it is also used as a \
1950                     per-row deadline compared against that column and the watermark — use an \
1951                     ORDER BY column whose type absorbs the bound (a timestamp or timestamptz \
1952                     column for an interval bound, rather than {ts_type})"
1953                )
1954            },
1955        )
1956        .into());
1957    }
1958    let predicate = ExprImpl::from(FunctionCall::new(
1959        ExprType::LessThanOrEqual,
1960        vec![last, first_plus_bound],
1961    )?);
1962    // The deadline is the same `first + bound`, but over a one-column synthetic row, so `first` is
1963    // `InputRef(0)` here rather than `InputRef(1)`.
1964    let first_only = ExprImpl::from(InputRef::new(0, ts_type.clone()));
1965    let deadline = ExprImpl::from(FunctionCall::new(ExprType::Add, vec![first_only, bound])?);
1966    // The check above is on the predicate's right-hand side; the DEADLINE is the expression the
1967    // executor actually evaluates and compares, so assert it directly rather than inferring that
1968    // identical operand types must infer identically.
1969    debug_assert_eq!(
1970        deadline.return_type(),
1971        ts_type,
1972        "the WITHIN deadline must keep the ORDER BY type; it is compared against the order key \
1973         and the watermark by `default_cmp`, which panics across variants"
1974    );
1975    Ok((predicate, deadline))
1976}
1977
1978#[cfg(test)]
1979mod tests {
1980    use risingwave_sqlparser::ast::RepetitionQuantifier as Q;
1981
1982    use super::*;
1983
1984    fn var(name: &str) -> MatchRecognizePattern {
1985        MatchRecognizePattern::Symbol(MatchRecognizeSymbol::Named(Ident::new_unchecked(name)))
1986    }
1987
1988    fn rep(inner: MatchRecognizePattern, q: Q) -> MatchRecognizePattern {
1989        MatchRecognizePattern::Repetition(Box::new(inner), q, false)
1990    }
1991
1992    /// The estimate must mirror `Nfa::build`: 2 states per variable, 2 more per optional copy.
1993    #[test]
1994    fn nfa_state_estimate_matches_the_expansion() {
1995        assert_eq!(estimate_nfa_states(&var("a")), 2);
1996        assert_eq!(estimate_nfa_states(&rep(var("a"), Q::Exactly(1000))), 2000);
1997        // 3 mandatory copies (2 each) plus 7 optional ones (2 + 2 each).
1998        assert_eq!(estimate_nfa_states(&rep(var("a"), Q::Range(3, 10))), 34);
1999        // `min` mandatory copies plus a `*` tail (inner + 2).
2000        assert_eq!(estimate_nfa_states(&rep(var("a"), Q::AtLeast(3))), 10);
2001        assert_eq!(estimate_nfa_states(&rep(var("a"), Q::ZeroOrMore)), 4);
2002        assert_eq!(estimate_nfa_states(&rep(var("a"), Q::OneOrMore)), 6);
2003        // `PERMUTE` over the maximum 6 variables: 6! orderings of 6 variables, plus the alternation's
2004        // own start and accept states. Quoted in `MAX_PATTERN_NFA_STATES`.
2005        let permute6 = MatchRecognizePattern::Permute(
2006            ["a", "b", "c", "d", "e", "f"]
2007                .iter()
2008                .map(|n| MatchRecognizeSymbol::Named(Ident::new_unchecked(*n)))
2009                .collect(),
2010        );
2011        assert_eq!(estimate_nfa_states(&permute6), 8642);
2012        assert!(estimate_nfa_states(&permute6) < MAX_PATTERN_NFA_STATES);
2013    }
2014
2015    /// The minimum-start-distance walk backing the physical-`PREV` rule: exact for the supported
2016    /// constructs, and the zero-minimum-prefix / alternation / `PERMUTE` corners each pin the case
2017    /// that would make the rule unsound if gotten wrong.
2018    #[test]
2019    fn min_start_distances_cover_the_pattern_constructs() {
2020        let concat = |ps: Vec<MatchRecognizePattern>| MatchRecognizePattern::Concat(ps);
2021        let d = |p: &MatchRecognizePattern, v: &str| min_start_distances(p).get(v).copied();
2022
2023        // Concatenation shifts by the preceding minimum length.
2024        let ab = concat(vec![var("a"), var("b")]);
2025        assert_eq!(d(&ab, "a"), Some(0));
2026        assert_eq!(d(&ab, "b"), Some(1));
2027        // A variable absent from the pattern has no distance.
2028        assert_eq!(d(&ab, "x"), None);
2029
2030        // A zero-minimum quantifier prefix contributes nothing: `(a* b)` leaves `b` at 0.
2031        let a_star_b = concat(vec![rep(var("a"), Q::ZeroOrMore), var("b")]);
2032        assert_eq!(d(&a_star_b, "b"), Some(0));
2033        // ...while a one-minimum prefix contributes its single row: `(a+ b)` puts `b` at 1.
2034        let a_plus_b = concat(vec![rep(var("a"), Q::OneOrMore), var("b")]);
2035        assert_eq!(d(&a_plus_b, "b"), Some(1));
2036        // `{n,...}` prefixes contribute `n` rows.
2037        let a3_b = concat(vec![rep(var("a"), Q::AtLeast(3)), var("b")]);
2038        assert_eq!(d(&a3_b, "b"), Some(3));
2039
2040        // Inside a quantified node the first iteration starts at the node's start: `b+` itself
2041        // leaves `b` at 0, even though later iterations sit further away.
2042        assert_eq!(d(&rep(var("b"), Q::OneOrMore), "b"), Some(0));
2043
2044        // Alternation takes the minimum branch length as a prefix: `s (a | b c) t`.
2045        let alt = concat(vec![
2046            var("s"),
2047            MatchRecognizePattern::Alternation(vec![var("a"), concat(vec![var("b"), var("c")])]),
2048            var("t"),
2049        ]);
2050        assert_eq!(d(&alt, "a"), Some(1));
2051        assert_eq!(d(&alt, "c"), Some(2));
2052        // `t` follows the alternation's *minimum* (1 row via the `a` branch).
2053        assert_eq!(d(&alt, "t"), Some(2));
2054
2055        // PERMUTE: any element can be ordered first.
2056        let permute = MatchRecognizePattern::Permute(vec![
2057            MatchRecognizeSymbol::Named(Ident::new_unchecked("a")),
2058            MatchRecognizeSymbol::Named(Ident::new_unchecked("b")),
2059        ]);
2060        assert_eq!(d(&permute, "b"), Some(0));
2061        // ...and a PERMUTE consumes all its elements as a prefix.
2062        let permute_t = concat(vec![permute, var("t")]);
2063        assert_eq!(d(&permute_t, "t"), Some(2));
2064
2065        // A variable occurring twice keeps its smallest distance.
2066        let twice = concat(vec![var("a"), var("b"), var("a")]);
2067        assert_eq!(d(&twice, "a"), Some(0));
2068    }
2069
2070    /// A bound whose addition PROMOTES the order-key type must be rejected at bind time.
2071    ///
2072    /// `smallint` order key with `WITHIN 2`: the literal binds as `int4`, so `first + bound` is
2073    /// `int4` while the order key and the watermark stay `int2`. Nothing downstream reconciles them,
2074    /// and the executor compares the deadline against both raw — `ScalarRefImpl::default_cmp`
2075    /// panics on mismatched variants, which is an actor panic as soon as rows flow and a crash loop
2076    /// after recovery replays them. `date` key with an interval bound promotes to `timestamp` the
2077    /// same way.
2078    ///
2079    /// Rejecting rather than casting the deadline back down is deliberate: truncating
2080    /// `timestamp -> date` closes the window early and prematurely evicts live partials, which is
2081    /// the same calendar-correctness trap documented on `lower_within`.
2082    #[test]
2083    fn within_bound_that_promotes_the_order_key_type_is_rejected() {
2084        let err = lower_within(DataType::Int16, ExprImpl::literal_int(2)).expect_err(
2085            "int4 bound over an int2 order key promotes the deadline and must not bind",
2086        );
2087        let msg = err.to_string();
2088        assert!(
2089            msg.contains("smallint") && msg.contains("integer"),
2090            "the error should name both types so the cast is obvious, got: {msg}"
2091        );
2092
2093        // The matching-type case still binds.
2094        lower_within(DataType::Int32, ExprImpl::literal_int(2))
2095            .expect("an int4 bound over an int4 order key keeps the type");
2096
2097        // And the documented way through — casting the bound to the order key's type — must work,
2098        // since `match_recognize_within.slt` tells users to do exactly that.
2099        let int2_bound = ExprImpl::from(Literal::new(Some(ScalarImpl::Int16(2)), DataType::Int16));
2100        lower_within(DataType::Int16, int2_bound)
2101            .expect("`WITHIN 2::smallint` over an int2 order key must keep the type");
2102    }
2103
2104    /// The executor's hot-path span check reuses the per-row deadline instead of evaluating the
2105    /// span predicate, which is sound only while the deadline IS the predicate's right-hand side.
2106    /// That relationship is established here, by building both from one `first + bound`, and is
2107    /// relied on in `DefineMatcher::matches` — so pin it: a future change that lowers the predicate
2108    /// differently (`(last - first) <= bound`, say, which is wrong for calendar intervals) would
2109    /// silently change which matches the operator produces, and this fails instead.
2110    #[test]
2111    fn within_predicate_right_hand_side_is_the_deadline() {
2112        let bound = ExprImpl::literal_int(5);
2113        let (predicate, deadline) = lower_within(DataType::Int32, bound).unwrap();
2114
2115        let ExprImpl::FunctionCall(pred) = &predicate else {
2116            panic!("the span predicate must be a function call, got {predicate:?}");
2117        };
2118        assert_eq!(pred.func_type(), ExprType::LessThanOrEqual);
2119        let [last, rhs] = pred.inputs() else {
2120            panic!("the span predicate must be binary");
2121        };
2122        assert_eq!(
2123            last,
2124            &ExprImpl::from(InputRef::new(0, DataType::Int32)),
2125            "`last` is column 0 of the synthetic [last, first] row"
2126        );
2127
2128        // The two differ only in where `first` is read from: column 1 of the two-column span row
2129        // versus column 0 of the one-column deadline row. The operator and the bound must be
2130        // identical, because that is exactly what makes them interchangeable.
2131        let ExprImpl::FunctionCall(rhs) = rhs else {
2132            panic!("the predicate's right-hand side must be `first + bound`, got {rhs:?}");
2133        };
2134        let ExprImpl::FunctionCall(dl) = &deadline else {
2135            panic!("the deadline must be a function call, got {deadline:?}");
2136        };
2137        assert_eq!(
2138            rhs.func_type(),
2139            ExprType::Add,
2140            "span rhs must be an addition"
2141        );
2142        assert_eq!(
2143            dl.func_type(),
2144            ExprType::Add,
2145            "deadline must be an addition"
2146        );
2147
2148        let [span_first, span_bound] = rhs.inputs() else {
2149            panic!("span rhs must be binary");
2150        };
2151        let [dl_first, dl_bound] = dl.inputs() else {
2152            panic!("deadline must be binary");
2153        };
2154        assert_eq!(
2155            span_first,
2156            &ExprImpl::from(InputRef::new(1, DataType::Int32)),
2157            "`first` is column 1 of the two-column span row"
2158        );
2159        assert_eq!(
2160            dl_first,
2161            &ExprImpl::from(InputRef::new(0, DataType::Int32)),
2162            "`first` is column 0 of the one-column deadline row"
2163        );
2164        assert_eq!(
2165            span_bound, dl_bound,
2166            "both must carry the SAME bound expression; if they diverge, the executor's hot-path \
2167             span check (which reuses the cached deadline) stops agreeing with the span predicate"
2168        );
2169    }
2170
2171    /// Nesting multiplies, so per-quantifier bounds within [`MAX_QUANTIFIER_BOUND`] are not enough.
2172    #[test]
2173    fn nested_quantifiers_within_the_per_bound_cap_are_still_rejected() {
2174        let nested = rep(
2175            MatchRecognizePattern::Group(Box::new(rep(var("a"), Q::Exactly(900)))),
2176            Q::Exactly(900),
2177        );
2178        assert!(check_pattern_bounds(&nested).is_ok());
2179        assert!(validate_pattern(&nested).is_err());
2180    }
2181
2182    #[test]
2183    fn inverted_and_oversized_bounds_are_rejected() {
2184        assert!(validate_pattern(&rep(var("a"), Q::Range(5, 3))).is_err());
2185        assert!(validate_pattern(&rep(var("a"), Q::Range(3, 5))).is_ok());
2186        assert!(validate_pattern(&rep(var("a"), Q::Exactly(MAX_QUANTIFIER_BOUND))).is_ok());
2187        assert!(validate_pattern(&rep(var("a"), Q::Exactly(MAX_QUANTIFIER_BOUND + 1))).is_err());
2188        assert!(validate_pattern(&rep(var("a"), Q::AtMost(u32::MAX))).is_err());
2189    }
2190
2191    fn permute(n: usize) -> MatchRecognizePattern {
2192        MatchRecognizePattern::Permute(
2193            (0..n)
2194                .map(|i| {
2195                    MatchRecognizeSymbol::Named(Ident::new_unchecked(format!("v{i}").as_str()))
2196                })
2197                .collect(),
2198        )
2199    }
2200
2201    /// An oversized `PERMUTE` is over the whole-pattern budget as well, so the arity check has to run
2202    /// first or the budget's quantifier-shaped message shadows the precise one.
2203    #[test]
2204    fn oversized_permute_reports_its_arity_not_the_state_budget() {
2205        // 7 variables is over the arity cap but *under* the state budget: the arity check is the only
2206        // thing that rejects it.
2207        assert_eq!(estimate_nfa_states(&permute(7)), 70562);
2208        assert!(estimate_nfa_states(&permute(7)) < MAX_PATTERN_NFA_STATES);
2209        let seven = validate_pattern(&permute(7)).unwrap_err().to_string();
2210        assert!(
2211            seven.contains("PERMUTE supports at most 6 variables"),
2212            "{seven}"
2213        );
2214
2215        // 8 variables is over both; the arity message must still be the one reported.
2216        assert!(estimate_nfa_states(&permute(8)) > MAX_PATTERN_NFA_STATES);
2217        let eight = validate_pattern(&permute(8)).unwrap_err().to_string();
2218        assert!(
2219            eight.contains("PERMUTE supports at most 6 variables"),
2220            "{eight}"
2221        );
2222        assert!(!eight.contains("NFA states"), "{eight}");
2223
2224        assert!(validate_pattern(&permute(MAX_PERMUTE_VARS)).is_ok());
2225    }
2226}