From 9d03b527222d5ea2ca8b8f855b3773d73185ec0d Mon Sep 17 00:00:00 2001 From: Mosha Pasumansky Date: Fri, 21 Aug 2026 13:34:41 -0700 Subject: [PATCH] Parser: fix exponential parse time on chains of `IN (` `parse_in` picks between a subquery and an expression list by speculatively parsing a query and rolling back. The list fallback then recurses back into `parse_in` over the same tail --- `parse_expr` accepts a reserved word as an identifier --- so each nesting level re-attempts the identical speculative parse. `"SELECT NOT IN(".repeat(20)` took 1.3s, and 26 levels 83s. Memoize the failed positions, as `parse_table_factor` already does for the `FROM ((((` shape it has the same structure as. A cached failure yields the same fallback as re-running the parse, so behaviour is unchanged. --- src/parser/mod.rs | 22 ++++++++++++++++- tests/sqlparser_common.rs | 51 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 84b8ff081..d9b97451d 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -373,6 +373,9 @@ pub struct Parser<'a> { /// `parse_table_factor`. See [`Parser::parse_table_factor`] for the 2^N /// pattern this guards. failed_derived_table_factor_positions: BTreeSet, + /// Cached failures from the speculative subquery arm of `parse_in`. See + /// [`Parser::parse_in`] for the 2^N pattern this guards. + failed_in_subquery_positions: BTreeSet, } /// Copy marker for a [`ParserError`] cached by the `parse_prefix` failure @@ -419,6 +422,7 @@ impl<'a> Parser<'a> { failed_prefix_positions: BTreeMap::new(), failed_reserved_word_prefix_positions: BTreeMap::new(), failed_derived_table_factor_positions: BTreeSet::new(), + failed_in_subquery_positions: BTreeSet::new(), } } @@ -483,6 +487,7 @@ impl<'a> Parser<'a> { self.failed_prefix_positions.clear(); self.failed_reserved_word_prefix_positions.clear(); self.failed_derived_table_factor_positions.clear(); + self.failed_in_subquery_positions.clear(); self } @@ -4417,7 +4422,22 @@ impl<'a> Parser<'a> { }); } self.expect_token(&Token::LParen)?; - let in_op = match self.maybe_parse(|p| p.parse_query())? { + // Memoize failures to break the 2^N work on inputs like `SELECT NOT IN(...`, where + // the list fallback recurses back into `parse_in` over the same tail and re-attempts + // the identical speculative parse. + let subquery_pos = self.index; + let subquery = if self.failed_in_subquery_positions.contains(&subquery_pos) { + None + } else { + match self.maybe_parse(|p| p.parse_query())? { + Some(subquery) => Some(subquery), + None => { + self.failed_in_subquery_positions.insert(subquery_pos); + None + } + } + }; + let in_op = match subquery { Some(subquery) => Expr::InSubquery { expr: Box::new(expr), subquery, diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 3f2f17b2c..9a3d537f5 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -19011,3 +19011,54 @@ fn parse_function_arg_call_chain_no_exponential_blowup() { rx.recv_timeout(Duration::from_secs(5)) .expect("parser should reject this quickly, not loop exponentially"); } + +/// A chain of `IN (` used to cost 2^depth: ~300 bytes took over 20 seconds. +#[test] +fn parse_in_chain_no_exponential_blowup() { + use std::sync::mpsc; + use std::thread; + use std::time::Duration; + + let sql = "SELECT NOT IN(\n".repeat(40); + + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let _ = Parser::new(&GenericDialect {}) + .with_recursion_limit(256) + .try_with_sql(&sql) + .and_then(|mut p| p.parse_statements()); + let _ = tx.send(()); + }); + + rx.recv_timeout(Duration::from_secs(5)) + .expect("parser should reject this quickly, not loop exponentially"); +} + +/// Asserting only that these parse would miss a silent `InSubquery` -> `InList` change, +/// since both succeed. +#[test] +fn parse_in_subquery_vs_list_dispatch() { + let cases = [ + ("SELECT 1 WHERE x IN (SELECT a FROM t)", true), + ( + "SELECT 1 WHERE x IN (WITH c AS (SELECT 1) SELECT * FROM c)", + true, + ), + ("SELECT 1 WHERE x IN (VALUES (1))", true), + ("SELECT 1 WHERE x IN (SELECT (1))", true), + ("SELECT 1 WHERE x IN (1, 2, 3)", false), + ("SELECT 1 WHERE x IN ((1), (2))", false), + ("SELECT 1 WHERE x IN (select)", false), + ("SELECT 1 WHERE x IN (select())", false), + ("SELECT 1 WHERE x IN (select.col)", false), + ]; + for (sql, expects_subquery) in cases { + let ast = Parser::new(&GenericDialect {}) + .try_with_sql(sql) + .and_then(|mut p| p.parse_statements()) + .unwrap_or_else(|e| panic!("{sql} should parse, got {e}")); + let rendered = format!("{ast:?}"); + assert_eq!(rendered.contains("InSubquery"), expects_subquery, "{sql}"); + assert_eq!(rendered.contains("InList"), !expects_subquery, "{sql}"); + } +}