From ae2a0b9f26c1a15fce1ac59142083424739f70bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BB=98=E5=85=B8?= Date: Thu, 13 Aug 2026 20:12:58 +0800 Subject: [PATCH] fix: support PostgreSQL composite row expansion (function()).* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 付典 --- .../sf/jsqlparser/util/TablesNamesFinder.java | 2 +- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 58 +++++++++++ .../select/CompositeRowExpansionTest.java | 97 +++++++++++++++++++ 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 src/test/java/net/sf/jsqlparser/statement/select/CompositeRowExpansionTest.java diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 6eea66a57..4b643742a 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -925,7 +925,7 @@ public Void visit(AllTableColumns allTableColumns, S context) { @Override public Void visit(FunctionAllColumns functionAllColumns, S context) { - + functionAllColumns.getFunction().accept(this, context); return null; } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 5da3aa181..0cf4530ed 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -594,6 +594,55 @@ public class CCJSqlParser extends AbstractJSqlParser { return getToken(i - 1).image.equals("*"); } + /** + * Detects PostgreSQL composite row expansion {@code (function()).*} (and any + * number of surrounding parentheses), where a parenthesised expression wrapping + * a single {@link Function} is immediately followed by {@code .*}. + * + *

This is a constant-time follower check (unwraps the already-parsed + * {@code retval} and peeks the next two tokens). It deliberately avoids the + * speculative syntactic lookahead that previously degraded performance, see + * issue #2207. + * + * @param retval the expression parsed so far within the + * {@code ParenthesedExpressionList} branch of {@code PrimaryExpression} + */ + protected boolean isFunctionAllColumnsAhead(Expression retval) { + // Fast follower gate: the overwhelming majority of parenthesised + // expressions are not followed by ".*", so reject on the token stream + // before ever touching the already-parsed expression. + if (!getToken(1).image.equals(".") || !getToken(2).image.equals("*")) { + return false; + } + if (retval == null) { + return false; + } + + Expression inner = retval; + while (inner instanceof ParenthesedExpressionList) { + ParenthesedExpressionList parenthesed = (ParenthesedExpressionList) inner; + if (parenthesed.size() != 1) { + return false; + } + inner = parenthesed.get(0); + } + + return inner instanceof Function; + } + + /** + * Unwraps any number of surrounding parentheses from a parenthesised + * {@link Function} and returns the inner function. Only call this after + * {@link #isFunctionAllColumnsAhead(Expression)} has confirmed the shape. + */ + protected Function unwrapParenthesedFunction(Expression retval) { + Expression inner = retval; + while (inner instanceof ParenthesedExpressionList) { + inner = ((ParenthesedExpressionList) inner).get(0); + } + return (Function) inner; + } + /** * Follower-based disambiguation for reserved keywords in ambiguous * positions (implicit alias, clause boundary, after parenthesised @@ -7903,6 +7952,15 @@ Expression PrimaryExpression() #PrimaryExpression: } ) + // PostgreSQL composite row expansion: (function()).* + // Re-enables FunctionAllColumns, which was disabled in #2207 due to the + // performance toll of a speculative syntactic lookahead. A bounded + // semantic follower check instead reads the already-parsed retval and + // peeks the next two tokens, avoiding any speculative production. + [ LOOKAHEAD( { isFunctionAllColumnsAhead(retval) } ) + "." "*" + { retval = new FunctionAllColumns(unwrapParenthesedFunction(retval)); } ] + // RowGet Expressions ( LOOKAHEAD(2) "." tmp=RelObjectName() { retval = new RowGetExpression(retval, tmp); } )* ) diff --git a/src/test/java/net/sf/jsqlparser/statement/select/CompositeRowExpansionTest.java b/src/test/java/net/sf/jsqlparser/statement/select/CompositeRowExpansionTest.java new file mode 100644 index 000000000..c2e2a09f8 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/select/CompositeRowExpansionTest.java @@ -0,0 +1,97 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2019 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.select; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; + +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * PostgreSQL composite row expansion {@code (function_returning_composite).*}. + * + *

+ * This feature was merged via #2207 and afterwards disabled, because the speculative syntactic + * lookahead used back then caused a severe performance regression. It is re-enabled here through a + * bounded semantic follower check, see {@code FunctionAllColumns} in the grammar. + */ +public class CompositeRowExpansionTest { + + private static FunctionAllColumns assertFunctionAllColumns(String sql) + throws JSQLParserException { + PlainSelect select = (PlainSelect) assertSqlCanBeParsedAndDeparsed(sql, true); + Expression expression = select.getSelectItems().get(0).getExpression(); + Assertions.assertTrue(expression instanceof FunctionAllColumns, + "Expected a FunctionAllColumns select item but got " + expression.getClass()); + return (FunctionAllColumns) expression; + } + + @Test + public void testIssue2412JsonPopulateRecord() throws JSQLParserException { + FunctionAllColumns result = assertFunctionAllColumns( + "SELECT (json_populate_record(NULL::users, data)).* FROM staging_users"); + Assertions.assertEquals("json_populate_record", result.getFunction().getName()); + } + + @Test + public void testSimpleFunctionAllColumns() throws JSQLParserException { + FunctionAllColumns result = assertFunctionAllColumns("SELECT (foo(a, b)).* FROM t"); + Assertions.assertEquals("foo", result.getFunction().getName()); + } + + @Test + public void testPgStatFileExampleFrom2207() throws JSQLParserException { + FunctionAllColumns result = assertFunctionAllColumns( + "SELECT (pg_stat_file('postgresql.conf')).*"); + Assertions.assertEquals("pg_stat_file", result.getFunction().getName()); + } + + @Test + public void testIssue2412InsertSelectUseCase() throws JSQLParserException { + assertSqlCanBeParsedAndDeparsed( + "INSERT INTO users SELECT (json_populate_record(NULL::users, data)).* FROM staging_users", + true); + } + + @Test + public void testMultipleSurroundingParensAreUnwrapped() throws JSQLParserException { + // Redundant parentheses around a single value are semantically transparent in + // PostgreSQL, so they are unwrapped to the inner function. The round-trip + // therefore normalises to a single surrounding pair. + PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse("SELECT ((((foo(a))))).* FROM t"); + Expression expression = select.getSelectItems().get(0).getExpression(); + Assertions.assertTrue(expression instanceof FunctionAllColumns); + Assertions.assertEquals("foo", ((FunctionAllColumns) expression).getFunction().getName()); + Assertions.assertEquals("(foo(a)).*", expression.toString()); + } + + @Test + public void testParenthesedFunctionWithoutExpansionUnchanged() throws JSQLParserException { + // Without the trailing .* a parenthesised function stays a plain expression. + assertSqlCanBeParsedAndDeparsed("SELECT (foo(a, b)) FROM t", true); + } + + @Test + public void testRowGetExpressionAfterParenthesedFunctionUnchanged() throws JSQLParserException { + // (function()).name must keep parsing as a RowGetExpression, not be swallowed. + assertSqlCanBeParsedAndDeparsed("SELECT (foo(a, b)).colname FROM t", true); + } + + @Test + public void testNonFunctionCompositeExpansionStillUnsupported() { + // Expanding an arbitrary (non-function) expression is out of scope and must + // keep failing cleanly instead of producing a wrong AST. + Assertions.assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse("SELECT (a + b).* FROM t")); + } +}