Skip to content

Commit 7ced34d

Browse files
authored
fix: support PostgreSQL composite row expansion (function()).* (#2463)
PostgreSQL expands a composite-returning function call into its columns with (function_call).*, e.g. SELECT (json_populate_record(NULL::users, data)).* FROM staging_users. JSQLParser rejected the trailing .* . Composite row expansion was implemented in #2207 but afterwards disabled, because the speculative syntactic LOOKAHEAD(FunctionAllColumns()) at the PrimaryExpression entry caused a severe regression (393 ms/op vs ~86 ms/op). The AST node, deparser, validator and all visitors stayed in place; only the grammar call site was commented out. Re-enable the feature without the speculative lookahead: after a ParenthesedExpressionList wrapping a single Function is parsed, a bounded semantic follower check (isFunctionAllColumnsAhead) peeks .* and wraps the result into FunctionAllColumns. The check first compares the next two tokens and only then unwraps the already-parsed expression, so the common path (a parenthesised expression not followed by .*) bails out in two comparisons without any speculative production or backtracking. TablesNamesFinder now descends into the wrapped function so column/table references inside the expansion are not lost. Scope: only (function_call).* is supported; arbitrary (non-function expression).* remains unsupported and fails cleanly as before. Redundant surrounding parentheses are unwrapped to the inner function. Performance (gradle jmh, parseSQLStatements on performance.sql, version=latest, 10 forks x 10 iterations, 100 samples, dedicated 32-core host): master (disabled): 3.632 +/- 0.020 ms/op this change: 3.639 +/- 0.023 ms/op The +0.19% delta lies within the confidence intervals and is far from the 393 ms/op toll that motivated disabling the feature. Testing: CompositeRowExpansionTest covers the issue case, simple and no-arg functions, the INSERT...SELECT use case, multiple surrounding parentheses, and negative cases (no trailing .*, RowGet expression, non-function expression). The positive tests fail on master and pass with this change. Fixes #2412 Signed-off-by: 付典 <fudianchn@gmail.com>
1 parent b5adb60 commit 7ced34d

3 files changed

Lines changed: 156 additions & 1 deletion

File tree

src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -925,7 +925,7 @@ public <S> Void visit(AllTableColumns allTableColumns, S context) {
925925

926926
@Override
927927
public <S> Void visit(FunctionAllColumns functionAllColumns, S context) {
928-
928+
functionAllColumns.getFunction().accept(this, context);
929929
return null;
930930
}
931931

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,55 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
594594
return getToken(i - 1).image.equals("*");
595595
}
596596

597+
/**
598+
* Detects PostgreSQL composite row expansion {@code (function()).*} (and any
599+
* number of surrounding parentheses), where a parenthesised expression wrapping
600+
* a single {@link Function} is immediately followed by {@code .*}.
601+
*
602+
* <p>This is a constant-time follower check (unwraps the already-parsed
603+
* {@code retval} and peeks the next two tokens). It deliberately avoids the
604+
* speculative syntactic lookahead that previously degraded performance, see
605+
* issue #2207.
606+
*
607+
* @param retval the expression parsed so far within the
608+
* {@code ParenthesedExpressionList} branch of {@code PrimaryExpression}
609+
*/
610+
protected boolean isFunctionAllColumnsAhead(Expression retval) {
611+
// Fast follower gate: the overwhelming majority of parenthesised
612+
// expressions are not followed by ".*", so reject on the token stream
613+
// before ever touching the already-parsed expression.
614+
if (!getToken(1).image.equals(".") || !getToken(2).image.equals("*")) {
615+
return false;
616+
}
617+
if (retval == null) {
618+
return false;
619+
}
620+
621+
Expression inner = retval;
622+
while (inner instanceof ParenthesedExpressionList) {
623+
ParenthesedExpressionList<?> parenthesed = (ParenthesedExpressionList<?>) inner;
624+
if (parenthesed.size() != 1) {
625+
return false;
626+
}
627+
inner = parenthesed.get(0);
628+
}
629+
630+
return inner instanceof Function;
631+
}
632+
633+
/**
634+
* Unwraps any number of surrounding parentheses from a parenthesised
635+
* {@link Function} and returns the inner function. Only call this after
636+
* {@link #isFunctionAllColumnsAhead(Expression)} has confirmed the shape.
637+
*/
638+
protected Function unwrapParenthesedFunction(Expression retval) {
639+
Expression inner = retval;
640+
while (inner instanceof ParenthesedExpressionList) {
641+
inner = ((ParenthesedExpressionList<?>) inner).get(0);
642+
}
643+
return (Function) inner;
644+
}
645+
597646
/**
598647
* Follower-based disambiguation for reserved keywords in ambiguous
599648
* positions (implicit alias, clause boundary, after parenthesised
@@ -7903,6 +7952,15 @@ Expression PrimaryExpression() #PrimaryExpression:
79037952
}
79047953
)
79057954

7955+
// PostgreSQL composite row expansion: (function()).*
7956+
// Re-enables FunctionAllColumns, which was disabled in #2207 due to the
7957+
// performance toll of a speculative syntactic lookahead. A bounded
7958+
// semantic follower check instead reads the already-parsed retval and
7959+
// peeks the next two tokens, avoiding any speculative production.
7960+
[ LOOKAHEAD( { isFunctionAllColumnsAhead(retval) } )
7961+
"." "*"
7962+
{ retval = new FunctionAllColumns(unwrapParenthesedFunction(retval)); } ]
7963+
79067964
// RowGet Expressions
79077965
( LOOKAHEAD(2) "." tmp=RelObjectName() { retval = new RowGetExpression(retval, tmp); } )*
79087966
)
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*-
2+
* #%L
3+
* JSQLParser library
4+
* %%
5+
* Copyright (C) 2004 - 2019 JSQLParser
6+
* %%
7+
* Dual licensed under GNU LGPL 2.1 or Apache License 2.0
8+
* #L%
9+
*/
10+
package net.sf.jsqlparser.statement.select;
11+
12+
import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed;
13+
14+
import net.sf.jsqlparser.JSQLParserException;
15+
import net.sf.jsqlparser.expression.Expression;
16+
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
17+
import org.junit.jupiter.api.Assertions;
18+
import org.junit.jupiter.api.Test;
19+
20+
/**
21+
* PostgreSQL composite row expansion {@code (function_returning_composite).*}.
22+
*
23+
* <p>
24+
* This feature was merged via #2207 and afterwards disabled, because the speculative syntactic
25+
* lookahead used back then caused a severe performance regression. It is re-enabled here through a
26+
* bounded semantic follower check, see {@code FunctionAllColumns} in the grammar.
27+
*/
28+
public class CompositeRowExpansionTest {
29+
30+
private static FunctionAllColumns assertFunctionAllColumns(String sql)
31+
throws JSQLParserException {
32+
PlainSelect select = (PlainSelect) assertSqlCanBeParsedAndDeparsed(sql, true);
33+
Expression expression = select.getSelectItems().get(0).getExpression();
34+
Assertions.assertTrue(expression instanceof FunctionAllColumns,
35+
"Expected a FunctionAllColumns select item but got " + expression.getClass());
36+
return (FunctionAllColumns) expression;
37+
}
38+
39+
@Test
40+
public void testIssue2412JsonPopulateRecord() throws JSQLParserException {
41+
FunctionAllColumns result = assertFunctionAllColumns(
42+
"SELECT (json_populate_record(NULL::users, data)).* FROM staging_users");
43+
Assertions.assertEquals("json_populate_record", result.getFunction().getName());
44+
}
45+
46+
@Test
47+
public void testSimpleFunctionAllColumns() throws JSQLParserException {
48+
FunctionAllColumns result = assertFunctionAllColumns("SELECT (foo(a, b)).* FROM t");
49+
Assertions.assertEquals("foo", result.getFunction().getName());
50+
}
51+
52+
@Test
53+
public void testPgStatFileExampleFrom2207() throws JSQLParserException {
54+
FunctionAllColumns result = assertFunctionAllColumns(
55+
"SELECT (pg_stat_file('postgresql.conf')).*");
56+
Assertions.assertEquals("pg_stat_file", result.getFunction().getName());
57+
}
58+
59+
@Test
60+
public void testIssue2412InsertSelectUseCase() throws JSQLParserException {
61+
assertSqlCanBeParsedAndDeparsed(
62+
"INSERT INTO users SELECT (json_populate_record(NULL::users, data)).* FROM staging_users",
63+
true);
64+
}
65+
66+
@Test
67+
public void testMultipleSurroundingParensAreUnwrapped() throws JSQLParserException {
68+
// Redundant parentheses around a single value are semantically transparent in
69+
// PostgreSQL, so they are unwrapped to the inner function. The round-trip
70+
// therefore normalises to a single surrounding pair.
71+
PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse("SELECT ((((foo(a))))).* FROM t");
72+
Expression expression = select.getSelectItems().get(0).getExpression();
73+
Assertions.assertTrue(expression instanceof FunctionAllColumns);
74+
Assertions.assertEquals("foo", ((FunctionAllColumns) expression).getFunction().getName());
75+
Assertions.assertEquals("(foo(a)).*", expression.toString());
76+
}
77+
78+
@Test
79+
public void testParenthesedFunctionWithoutExpansionUnchanged() throws JSQLParserException {
80+
// Without the trailing .* a parenthesised function stays a plain expression.
81+
assertSqlCanBeParsedAndDeparsed("SELECT (foo(a, b)) FROM t", true);
82+
}
83+
84+
@Test
85+
public void testRowGetExpressionAfterParenthesedFunctionUnchanged() throws JSQLParserException {
86+
// (function()).name must keep parsing as a RowGetExpression, not be swallowed.
87+
assertSqlCanBeParsedAndDeparsed("SELECT (foo(a, b)).colname FROM t", true);
88+
}
89+
90+
@Test
91+
public void testNonFunctionCompositeExpansionStillUnsupported() {
92+
// Expanding an arbitrary (non-function) expression is out of scope and must
93+
// keep failing cleanly instead of producing a wrong AST.
94+
Assertions.assertThrows(JSQLParserException.class,
95+
() -> CCJSqlParserUtil.parse("SELECT (a + b).* FROM t"));
96+
}
97+
}

0 commit comments

Comments
 (0)