Skip to content

Commit 33b03e8

Browse files
authored
fix: memoize long-chain predicate walks behind a size threshold (#2520)
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: 付典 <fudianchn@gmail.com>
1 parent 9a32ff5 commit 33b03e8

2 files changed

Lines changed: 205 additions & 16 deletions

File tree

src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt

Lines changed: 111 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -347,15 +347,55 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
347347
* from column references like col, schema.col, a.b.c.col.
348348
*
349349
* Replaces LOOKAHEAD(16) on Function() with a targeted O(chain-length) check.
350+
* Long chains are memoized per start token: the phase-2 lookahead routines
351+
* re-evaluate the same walk many times per position and each chain part
352+
* would walk the very same suffix again, which makes long chains parse in
353+
* quadratic time. Chains up to CHAIN_CACHE_THRESHOLD parts skip the cache
354+
* entirely, keeping the normal short-name path allocation- and map-free.
350355
*/
356+
private static final int CHAIN_CACHE_THRESHOLD = 8;
357+
358+
// Sentinel returned by the chain walks when the non-filling pass ran past
359+
// CHAIN_CACHE_THRESHOLD delimiter/part pairs and the caller must retry
360+
// through the per-token cache.
361+
private static final int LONG_CHAIN = -1;
362+
363+
private final Map<Token, Boolean> isFunctionAheadCache = new HashMap<Token, Boolean>();
364+
351365
protected boolean isFunctionAhead() {
366+
int r = isFunctionAheadWalk(false);
367+
if (r != LONG_CHAIN) {
368+
return r == 1;
369+
}
370+
Token key = getToken(1);
371+
Boolean cached = isFunctionAheadCache.get(key);
372+
if (cached != null) {
373+
return cached;
374+
}
375+
boolean result = isFunctionAheadWalk(true) == 1;
376+
isFunctionAheadCache.put(key, result);
377+
return result;
378+
}
379+
380+
// True when a fresh evaluation started at this token would pass all
381+
// first-token guards and run the same chain walk.
382+
private boolean isFunctionAheadChainStartEligible(Token t) {
383+
return !t.image.equals("{") && t.kind != K_APPROXIMATE && !isNonFunctionKeyword(t)
384+
&& t.kind != S_LONG && t.kind != S_DOUBLE && t.kind != S_HEX
385+
&& t.kind != S_CHAR_LITERAL && t.kind != OPENING_BRACKET
386+
&& t.kind != CLOSING_BRACKET && t.kind != EOF;
387+
}
388+
389+
// Returns 1 for true, 0 for false, or LONG_CHAIN when the walk exceeded
390+
// CHAIN_CACHE_THRESHOLD delimiter/part pairs (only in the non-filling pass).
391+
private int isFunctionAheadWalk(boolean fill) {
352392
try {
353393
int i = 1;
354394
Token t = getToken(i);
355395

356396
// JDBC escape function: {fn ...} — must check for FN keyword
357397
if (t.image.equals("{")) {
358-
return getToken(2).kind == K_FN;
398+
return getToken(2).kind == K_FN ? 1 : 0;
359399
}
360400

361401
// Optional APPROXIMATE keyword
@@ -367,43 +407,54 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
367407
// Exclude tokens that have their own dedicated branches
368408
// after Function() in PrimaryExpression
369409
if (isNonFunctionKeyword(t)) {
370-
return false;
410+
return 0;
371411
}
372412

373413
// First token must not be a literal, bracket, or EOF
374414
if (t.kind == S_LONG || t.kind == S_DOUBLE || t.kind == S_HEX
375415
|| t.kind == S_CHAR_LITERAL || t.kind == OPENING_BRACKET
376416
|| t.kind == CLOSING_BRACKET || t.kind == EOF) {
377-
return false;
417+
return 0;
378418
}
379419
i++;
380420

381421
// Walk through dotted name chain
422+
int delimiters = 0;
423+
List<Token> chainParts = fill ? new ArrayList<Token>() : null;
382424
while (true) {
383425
t = getToken(i);
384426
if (t.image.equals(".") || t.image.equals("..")
385427
|| t.image.equals("...") || t.image.equals(":")) {
428+
if (!fill && ++delimiters > CHAIN_CACHE_THRESHOLD) {
429+
return LONG_CHAIN;
430+
}
431+
if (fill) {
432+
chainParts.add(getToken(i + 1));
433+
}
386434
i++; // skip delimiter
387435
i++; // skip next name part
388436
} else {
389437
break;
390438
}
391439
}
392440

393-
// Must be followed by (
394-
if (getToken(i).kind != OPENING_BRACKET) {
395-
return false;
396-
}
441+
// Must be followed by (, and not by the Oracle join syntax column(+)
442+
boolean result = getToken(i).kind == OPENING_BRACKET
443+
&& !(getToken(i + 1).image.equals("+")
444+
&& getToken(i + 2).kind == CLOSING_BRACKET);
397445

398-
// Exclude Oracle join syntax: column(+)
399-
if (getToken(i + 1).image.equals("+")
400-
&& getToken(i + 2).kind == CLOSING_BRACKET) {
401-
return false;
446+
// Each eligible chain part would walk the same remaining suffix and
447+
// obtain the same answer, so memoize them all in one pass.
448+
if (fill) {
449+
for (Token part : chainParts) {
450+
if (isFunctionAheadChainStartEligible(part)) {
451+
isFunctionAheadCache.put(part, result);
452+
}
453+
}
402454
}
403-
404-
return true;
455+
return result ? 1 : 0;
405456
} catch (TokenMgrException e) {
406-
return false;
457+
return 0;
407458
}
408459
}
409460

@@ -736,24 +787,54 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
736787
/**
737788
* Scans ahead through a dotted identifier chain and checks if '*' follows.
738789
* Identifies table.* patterns for AllTableColumns.
790+
* Long chains are memoized per start token like {@link #isFunctionAhead()}
791+
* (one walk fills every eligible chain part); short chains skip the cache.
739792
*/
793+
private final Map<Token, Boolean> isAllTableColumnsAheadCache =
794+
new HashMap<Token, Boolean>();
795+
740796
protected boolean isAllTableColumnsAhead() {
797+
int r = isAllTableColumnsWalk(false);
798+
if (r != LONG_CHAIN) {
799+
return r == 1;
800+
}
801+
Token key = getToken(1);
802+
Boolean cached = isAllTableColumnsAheadCache.get(key);
803+
if (cached != null) {
804+
return cached;
805+
}
806+
boolean result = isAllTableColumnsWalk(true) == 1;
807+
isAllTableColumnsAheadCache.put(key, result);
808+
return result;
809+
}
810+
811+
// Returns 1 for true, 0 for false, or LONG_CHAIN when the walk exceeded
812+
// CHAIN_CACHE_THRESHOLD delimiter/part pairs (only in the non-filling pass).
813+
private int isAllTableColumnsWalk(boolean fill) {
741814
int i = 1;
742815
Token t = getToken(i);
743816

744817
// Must start with a name-like token
745818
if (t.kind == S_LONG || t.kind == S_DOUBLE || t.kind == S_HEX
746819
|| t.kind == S_CHAR_LITERAL || t.kind == OPENING_BRACKET
747820
|| t.kind == CLOSING_BRACKET || t.kind == EOF) {
748-
return false;
821+
return 0;
749822
}
750823
i++;
751824

752825
// Walk through dotted name chain
826+
int delimiters = 0;
827+
List<Token> chainParts = fill ? new ArrayList<Token>() : null;
753828
while (true) {
754829
t = getToken(i);
755830
if (t.image.equals(".") || t.image.equals("..")
756831
|| t.image.equals("...")) {
832+
if (!fill && ++delimiters > CHAIN_CACHE_THRESHOLD) {
833+
return LONG_CHAIN;
834+
}
835+
if (fill) {
836+
chainParts.add(getToken(i + 1));
837+
}
757838
i++; // skip delimiter
758839
i++; // skip next part (could be "*")
759840
} else {
@@ -764,7 +845,21 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
764845
// It's AllTableColumns if the chain ended on "*"
765846
// i.e., the last name part we skipped over was "*"
766847
// Back up: the last token consumed was at (i-1)
767-
return getToken(i - 1).image.equals("*");
848+
boolean result = getToken(i - 1).image.equals("*");
849+
850+
// Each chain part that would pass the name-like guard walks the same
851+
// remaining suffix and obtains the same answer.
852+
if (fill) {
853+
for (Token part : chainParts) {
854+
if (part.kind == S_LONG || part.kind == S_DOUBLE || part.kind == S_HEX
855+
|| part.kind == S_CHAR_LITERAL || part.kind == OPENING_BRACKET
856+
|| part.kind == CLOSING_BRACKET || part.kind == EOF) {
857+
continue;
858+
}
859+
isAllTableColumnsAheadCache.put(part, result);
860+
}
861+
}
862+
return result ? 1 : 0;
768863
}
769864

770865
/**
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package net.sf.jsqlparser.parser;
2+
3+
import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed;
4+
import static org.junit.jupiter.api.Assertions.assertFalse;
5+
import static org.junit.jupiter.api.Assertions.assertTrue;
6+
7+
import net.sf.jsqlparser.expression.Function;
8+
import net.sf.jsqlparser.schema.Column;
9+
import net.sf.jsqlparser.statement.select.AllTableColumns;
10+
import net.sf.jsqlparser.statement.select.PlainSelect;
11+
import net.sf.jsqlparser.statement.select.Select;
12+
import org.junit.jupiter.api.Test;
13+
14+
/**
15+
* Guards the threshold-gated chain-walk memoization in isFunctionAhead() /
16+
* isAllTableColumnsAhead(): chains on both sides of CHAIN_CACHE_THRESHOLD and inside the memoized
17+
* path must keep the exact same AST shapes as the plain walk. The speed-up itself is
18+
* constant-factor only and is carried by the measured numbers, not by a CI timing assertion.
19+
*/
20+
public class LongChainPredicateTest {
21+
22+
private static String chain(int innerDelimiters, String last) {
23+
// innerDelimiters = dots between s0..sN (chain length before the last part)
24+
StringBuilder sb = new StringBuilder("s0");
25+
for (int i = 1; i <= innerDelimiters; i++) {
26+
sb.append(".s").append(i);
27+
}
28+
return sb.append(".").append(last).toString();
29+
}
30+
31+
private Object firstExpression(String sql) throws Exception {
32+
Select select = (Select) CCJSqlParserUtil.parse(sql);
33+
return ((PlainSelect) select).getSelectItems().get(0).getExpression();
34+
}
35+
36+
@Test
37+
void functionOnShortChainFastPath() throws Exception {
38+
// total 8 delimiters: the walk stays inside the plain non-cached path
39+
String sql = "SELECT " + chain(7, "f(1)") + " FROM t";
40+
assertSqlCanBeParsedAndDeparsed(sql);
41+
assertTrue(firstExpression(sql) instanceof Function,
42+
"8-delimiter chain ending in ( must stay a Function");
43+
}
44+
45+
@Test
46+
void functionOnLongChainMemoPath() throws Exception {
47+
// total 9 delimiters: the walk aborts past the threshold and goes through the cache
48+
String sql = "SELECT " + chain(8, "f(1)") + " FROM t";
49+
assertSqlCanBeParsedAndDeparsed(sql);
50+
assertTrue(firstExpression(sql) instanceof Function,
51+
"9-delimiter chain ending in ( must stay a Function on the memoized path");
52+
}
53+
54+
@Test
55+
void columnOnShortChainFastPath() throws Exception {
56+
String sql = "SELECT " + chain(7, "col") + " FROM t";
57+
assertSqlCanBeParsedAndDeparsed(sql);
58+
assertTrue(firstExpression(sql) instanceof Column);
59+
assertFalse(firstExpression(sql) instanceof Function);
60+
}
61+
62+
@Test
63+
void columnOnLongChainMemoPath() throws Exception {
64+
String sql = "SELECT " + chain(8, "col") + " FROM t";
65+
assertSqlCanBeParsedAndDeparsed(sql);
66+
assertTrue(firstExpression(sql) instanceof Column);
67+
assertFalse(firstExpression(sql) instanceof Function);
68+
}
69+
70+
@Test
71+
void columnOnVeryLongChainMemoPath() throws Exception {
72+
String sql = "SELECT " + chain(40, "col") + " FROM t";
73+
assertSqlCanBeParsedAndDeparsed(sql);
74+
assertTrue(firstExpression(sql) instanceof Column);
75+
}
76+
77+
@Test
78+
void allTableColumnsAcrossThreshold() throws Exception {
79+
String shortChain = "SELECT " + chain(7, "*") + " FROM t";
80+
assertSqlCanBeParsedAndDeparsed(shortChain);
81+
assertTrue(firstExpression(shortChain) instanceof AllTableColumns);
82+
83+
String longChain = "SELECT " + chain(8, "*") + " FROM t";
84+
assertSqlCanBeParsedAndDeparsed(longChain);
85+
assertTrue(firstExpression(longChain) instanceof AllTableColumns);
86+
}
87+
88+
@Test
89+
void oracleOuterJoinColumnPlusOnMemoPath() throws Exception {
90+
// the column(+) exclusion must survive the memoized walk
91+
assertSqlCanBeParsedAndDeparsed(
92+
"SELECT * FROM a, b WHERE " + chain(8, "x(+)") + " = b.x");
93+
}
94+
}

0 commit comments

Comments
 (0)