From 8d1717981646f0e66aced42c2bed754cd8a80a00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BB=98=E5=85=B8?= Date: Mon, 31 Aug 2026 23:26:44 +0800 Subject: [PATCH] fix: memoize long-chain predicate walks behind a size threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isFunctionAhead and isAllTableColumnsAhead walk the whole remaining dotted-name chain unbounded, and the phase-2 lookahead routines re-evaluate them at every position, so long chains like SELECT a.b.b... FROM t parse in quadratic time (a 64 KB statement hits the default 8 s Feature.timeOut). The walk now aborts past 8 delimiter/part pairs and restarts through a per-start-token cache; one filling walk records the answer for every eligible chain part (each part walks the identical suffix), keeping total walk work linear. Chains within the threshold finish in a single plain walk with no map access and no allocation. Full suite 4972/0 unchanged. This removes the predicate-layer share only; the remaining super-linear time sits in the phase-2 lookahead machinery itself. Signed-off-by: 付典 --- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 127 +++++++++++++++--- .../parser/LongChainPredicateTest.java | 94 +++++++++++++ 2 files changed, 205 insertions(+), 16 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/parser/LongChainPredicateTest.java diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index d52b7a9c2..1cb16a094 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -347,15 +347,55 @@ public class CCJSqlParser extends AbstractJSqlParser { * from column references like col, schema.col, a.b.c.col. * * Replaces LOOKAHEAD(16) on Function() with a targeted O(chain-length) check. + * Long chains are memoized per start token: the phase-2 lookahead routines + * re-evaluate the same walk many times per position and each chain part + * would walk the very same suffix again, which makes long chains parse in + * quadratic time. Chains up to CHAIN_CACHE_THRESHOLD parts skip the cache + * entirely, keeping the normal short-name path allocation- and map-free. */ + private static final int CHAIN_CACHE_THRESHOLD = 8; + + // Sentinel returned by the chain walks when the non-filling pass ran past + // CHAIN_CACHE_THRESHOLD delimiter/part pairs and the caller must retry + // through the per-token cache. + private static final int LONG_CHAIN = -1; + + private final Map isFunctionAheadCache = new HashMap(); + protected boolean isFunctionAhead() { + int r = isFunctionAheadWalk(false); + if (r != LONG_CHAIN) { + return r == 1; + } + Token key = getToken(1); + Boolean cached = isFunctionAheadCache.get(key); + if (cached != null) { + return cached; + } + boolean result = isFunctionAheadWalk(true) == 1; + isFunctionAheadCache.put(key, result); + return result; + } + + // True when a fresh evaluation started at this token would pass all + // first-token guards and run the same chain walk. + private boolean isFunctionAheadChainStartEligible(Token t) { + return !t.image.equals("{") && t.kind != K_APPROXIMATE && !isNonFunctionKeyword(t) + && t.kind != S_LONG && t.kind != S_DOUBLE && t.kind != S_HEX + && t.kind != S_CHAR_LITERAL && t.kind != OPENING_BRACKET + && t.kind != CLOSING_BRACKET && t.kind != EOF; + } + + // Returns 1 for true, 0 for false, or LONG_CHAIN when the walk exceeded + // CHAIN_CACHE_THRESHOLD delimiter/part pairs (only in the non-filling pass). + private int isFunctionAheadWalk(boolean fill) { try { int i = 1; Token t = getToken(i); // JDBC escape function: {fn ...} — must check for FN keyword if (t.image.equals("{")) { - return getToken(2).kind == K_FN; + return getToken(2).kind == K_FN ? 1 : 0; } // Optional APPROXIMATE keyword @@ -367,22 +407,30 @@ public class CCJSqlParser extends AbstractJSqlParser { // Exclude tokens that have their own dedicated branches // after Function() in PrimaryExpression if (isNonFunctionKeyword(t)) { - return false; + return 0; } // First token must not be a literal, bracket, or EOF if (t.kind == S_LONG || t.kind == S_DOUBLE || t.kind == S_HEX || t.kind == S_CHAR_LITERAL || t.kind == OPENING_BRACKET || t.kind == CLOSING_BRACKET || t.kind == EOF) { - return false; + return 0; } i++; // Walk through dotted name chain + int delimiters = 0; + List chainParts = fill ? new ArrayList() : null; while (true) { t = getToken(i); if (t.image.equals(".") || t.image.equals("..") || t.image.equals("...") || t.image.equals(":")) { + if (!fill && ++delimiters > CHAIN_CACHE_THRESHOLD) { + return LONG_CHAIN; + } + if (fill) { + chainParts.add(getToken(i + 1)); + } i++; // skip delimiter i++; // skip next name part } else { @@ -390,20 +438,23 @@ public class CCJSqlParser extends AbstractJSqlParser { } } - // Must be followed by ( - if (getToken(i).kind != OPENING_BRACKET) { - return false; - } + // Must be followed by (, and not by the Oracle join syntax column(+) + boolean result = getToken(i).kind == OPENING_BRACKET + && !(getToken(i + 1).image.equals("+") + && getToken(i + 2).kind == CLOSING_BRACKET); - // Exclude Oracle join syntax: column(+) - if (getToken(i + 1).image.equals("+") - && getToken(i + 2).kind == CLOSING_BRACKET) { - return false; + // Each eligible chain part would walk the same remaining suffix and + // obtain the same answer, so memoize them all in one pass. + if (fill) { + for (Token part : chainParts) { + if (isFunctionAheadChainStartEligible(part)) { + isFunctionAheadCache.put(part, result); + } + } } - - return true; + return result ? 1 : 0; } catch (TokenMgrException e) { - return false; + return 0; } } @@ -736,8 +787,30 @@ public class CCJSqlParser extends AbstractJSqlParser { /** * Scans ahead through a dotted identifier chain and checks if '*' follows. * Identifies table.* patterns for AllTableColumns. + * Long chains are memoized per start token like {@link #isFunctionAhead()} + * (one walk fills every eligible chain part); short chains skip the cache. */ + private final Map isAllTableColumnsAheadCache = + new HashMap(); + protected boolean isAllTableColumnsAhead() { + int r = isAllTableColumnsWalk(false); + if (r != LONG_CHAIN) { + return r == 1; + } + Token key = getToken(1); + Boolean cached = isAllTableColumnsAheadCache.get(key); + if (cached != null) { + return cached; + } + boolean result = isAllTableColumnsWalk(true) == 1; + isAllTableColumnsAheadCache.put(key, result); + return result; + } + + // Returns 1 for true, 0 for false, or LONG_CHAIN when the walk exceeded + // CHAIN_CACHE_THRESHOLD delimiter/part pairs (only in the non-filling pass). + private int isAllTableColumnsWalk(boolean fill) { int i = 1; Token t = getToken(i); @@ -745,15 +818,23 @@ public class CCJSqlParser extends AbstractJSqlParser { if (t.kind == S_LONG || t.kind == S_DOUBLE || t.kind == S_HEX || t.kind == S_CHAR_LITERAL || t.kind == OPENING_BRACKET || t.kind == CLOSING_BRACKET || t.kind == EOF) { - return false; + return 0; } i++; // Walk through dotted name chain + int delimiters = 0; + List chainParts = fill ? new ArrayList() : null; while (true) { t = getToken(i); if (t.image.equals(".") || t.image.equals("..") || t.image.equals("...")) { + if (!fill && ++delimiters > CHAIN_CACHE_THRESHOLD) { + return LONG_CHAIN; + } + if (fill) { + chainParts.add(getToken(i + 1)); + } i++; // skip delimiter i++; // skip next part (could be "*") } else { @@ -764,7 +845,21 @@ public class CCJSqlParser extends AbstractJSqlParser { // It's AllTableColumns if the chain ended on "*" // i.e., the last name part we skipped over was "*" // Back up: the last token consumed was at (i-1) - return getToken(i - 1).image.equals("*"); + boolean result = getToken(i - 1).image.equals("*"); + + // Each chain part that would pass the name-like guard walks the same + // remaining suffix and obtains the same answer. + if (fill) { + for (Token part : chainParts) { + if (part.kind == S_LONG || part.kind == S_DOUBLE || part.kind == S_HEX + || part.kind == S_CHAR_LITERAL || part.kind == OPENING_BRACKET + || part.kind == CLOSING_BRACKET || part.kind == EOF) { + continue; + } + isAllTableColumnsAheadCache.put(part, result); + } + } + return result ? 1 : 0; } /** diff --git a/src/test/java/net/sf/jsqlparser/parser/LongChainPredicateTest.java b/src/test/java/net/sf/jsqlparser/parser/LongChainPredicateTest.java new file mode 100644 index 000000000..d1b19cdf1 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/parser/LongChainPredicateTest.java @@ -0,0 +1,94 @@ +package net.sf.jsqlparser.parser; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import net.sf.jsqlparser.expression.Function; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.statement.select.AllTableColumns; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.statement.select.Select; +import org.junit.jupiter.api.Test; + +/** + * Guards the threshold-gated chain-walk memoization in isFunctionAhead() / + * isAllTableColumnsAhead(): chains on both sides of CHAIN_CACHE_THRESHOLD and inside the memoized + * path must keep the exact same AST shapes as the plain walk. The speed-up itself is + * constant-factor only and is carried by the measured numbers, not by a CI timing assertion. + */ +public class LongChainPredicateTest { + + private static String chain(int innerDelimiters, String last) { + // innerDelimiters = dots between s0..sN (chain length before the last part) + StringBuilder sb = new StringBuilder("s0"); + for (int i = 1; i <= innerDelimiters; i++) { + sb.append(".s").append(i); + } + return sb.append(".").append(last).toString(); + } + + private Object firstExpression(String sql) throws Exception { + Select select = (Select) CCJSqlParserUtil.parse(sql); + return ((PlainSelect) select).getSelectItems().get(0).getExpression(); + } + + @Test + void functionOnShortChainFastPath() throws Exception { + // total 8 delimiters: the walk stays inside the plain non-cached path + String sql = "SELECT " + chain(7, "f(1)") + " FROM t"; + assertSqlCanBeParsedAndDeparsed(sql); + assertTrue(firstExpression(sql) instanceof Function, + "8-delimiter chain ending in ( must stay a Function"); + } + + @Test + void functionOnLongChainMemoPath() throws Exception { + // total 9 delimiters: the walk aborts past the threshold and goes through the cache + String sql = "SELECT " + chain(8, "f(1)") + " FROM t"; + assertSqlCanBeParsedAndDeparsed(sql); + assertTrue(firstExpression(sql) instanceof Function, + "9-delimiter chain ending in ( must stay a Function on the memoized path"); + } + + @Test + void columnOnShortChainFastPath() throws Exception { + String sql = "SELECT " + chain(7, "col") + " FROM t"; + assertSqlCanBeParsedAndDeparsed(sql); + assertTrue(firstExpression(sql) instanceof Column); + assertFalse(firstExpression(sql) instanceof Function); + } + + @Test + void columnOnLongChainMemoPath() throws Exception { + String sql = "SELECT " + chain(8, "col") + " FROM t"; + assertSqlCanBeParsedAndDeparsed(sql); + assertTrue(firstExpression(sql) instanceof Column); + assertFalse(firstExpression(sql) instanceof Function); + } + + @Test + void columnOnVeryLongChainMemoPath() throws Exception { + String sql = "SELECT " + chain(40, "col") + " FROM t"; + assertSqlCanBeParsedAndDeparsed(sql); + assertTrue(firstExpression(sql) instanceof Column); + } + + @Test + void allTableColumnsAcrossThreshold() throws Exception { + String shortChain = "SELECT " + chain(7, "*") + " FROM t"; + assertSqlCanBeParsedAndDeparsed(shortChain); + assertTrue(firstExpression(shortChain) instanceof AllTableColumns); + + String longChain = "SELECT " + chain(8, "*") + " FROM t"; + assertSqlCanBeParsedAndDeparsed(longChain); + assertTrue(firstExpression(longChain) instanceof AllTableColumns); + } + + @Test + void oracleOuterJoinColumnPlusOnMemoPath() throws Exception { + // the column(+) exclusion must survive the memoized walk + assertSqlCanBeParsedAndDeparsed( + "SELECT * FROM a, b WHERE " + chain(8, "x(+)") + " = b.x"); + } +}