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}