From 4d4c640b9051ba9b317242745ccc726c0d5bc3b2 Mon Sep 17 00:00:00 2001 From: minleejae Date: Sat, 12 Sep 2026 19:57:27 +0900 Subject: [PATCH] Support structured XMLFOREST arguments with aliases --- .../expression/AliasedExpression.java | 59 ++++++++++ .../expression/ExpressionVisitor.java | 9 ++ .../sf/jsqlparser/util/TablesNamesFinder.java | 7 ++ .../util/deparser/ExpressionDeParser.java | 7 ++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 26 ++++- .../statement/select/XmlForestTest.java | 105 ++++++++++++++++++ 6 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/sf/jsqlparser/expression/AliasedExpression.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/select/XmlForestTest.java diff --git a/src/main/java/net/sf/jsqlparser/expression/AliasedExpression.java b/src/main/java/net/sf/jsqlparser/expression/AliasedExpression.java new file mode 100644 index 000000000..745cdf425 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/expression/AliasedExpression.java @@ -0,0 +1,59 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.expression; + +import java.util.function.Consumer; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; + +/** An expression with an alias, such as an XMLFOREST argument. */ +public class AliasedExpression extends ASTNodeAccessImpl implements Expression { + private Expression expression; + private Alias alias; + + public AliasedExpression(Expression expression, Alias alias) { + this.expression = expression; + this.alias = alias; + } + + public Expression getExpression() { + return expression; + } + + public void setExpression(Expression expression) { + this.expression = expression; + } + + public Alias getAlias() { + return alias; + } + + public void setAlias(Alias alias) { + this.alias = alias; + } + + @Override + public T accept(ExpressionVisitor visitor, S context) { + return visitor.visit(this, context); + } + + public StringBuilder appendTo(StringBuilder builder, Consumer expressionPrinter) { + expressionPrinter.accept(expression); + if (alias != null) { + builder.append(alias); + } + return builder; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java index 589f5b195..9d20144c9 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java +++ b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java @@ -72,6 +72,15 @@ import net.sf.jsqlparser.statement.update.UpdateSet; public interface ExpressionVisitor { + /** Visits the value of an aliased expression by default, preserving existing visitors. */ + default T visit(AliasedExpression expression, S context) { + return expression.getExpression().accept(this, context); + } + + default void visit(AliasedExpression expression) { + visit(expression, null); + } + default T visit(ExecuteArgument argument, S context) { return argument.getExpression().accept(this, context); diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 5931c2fa6..2d472bade 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -9,6 +9,8 @@ */ package net.sf.jsqlparser.util; +import net.sf.jsqlparser.expression.AliasedExpression; + import net.sf.jsqlparser.statement.oracle.OracleBlock; import net.sf.jsqlparser.statement.oracle.OracleAssignment; import net.sf.jsqlparser.statement.oracle.OracleNullStatement; @@ -2435,6 +2437,11 @@ public void visit(IfElseStatement ifElseStatement) { StatementVisitor.super.visit(ifElseStatement); } + @Override + public Void visit(AliasedExpression expression, S context) { + return expression.getExpression().accept(this, context); + } + @Override public Void visit(OracleNamedFunctionParameter oracleNamedFunctionParameter, S context) { oracleNamedFunctionParameter.getExpression().accept(this, context); diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java index 304998b1a..1fbec9ef6 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java @@ -9,6 +9,8 @@ */ package net.sf.jsqlparser.util.deparser; +import net.sf.jsqlparser.expression.AliasedExpression; + import static java.util.stream.Collectors.joining; import java.util.Iterator; @@ -169,6 +171,11 @@ public ExpressionDeParser(SelectVisitor selectVisitor, StringBuil this.orderByDeParser = orderByDeParser; } + @Override + public StringBuilder visit(AliasedExpression expression, S context) { + return expression.appendTo(builder, value -> value.accept(this, context)); + } + @Override public StringBuilder visit(Addition addition, S context) { deparse(addition, " + ", null); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 92f99568d..b8c0ac6b9 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -794,7 +794,7 @@ public class CCJSqlParser extends AbstractJSqlParser { } // only a bare leading keyword: an expression must follow Token next = getToken(2); - if (next.kind == EOF || ")".equals(next.image) || ",".equals(next.image) + if (next.kind == EOF || next.kind == K_AS || ")".equals(next.image) || ",".equals(next.image) || ".".equals(next.image) || "(".equals(next.image)) { return; } @@ -11558,6 +11558,7 @@ ExpressionList FunctionArgumentList(Function retval): Token extraKeywordToken; } { + { if ("XMLFOREST".equalsIgnoreCase(retval.getName())) { return XmlForestArguments(); } } [ LOOKAHEAD(2) extraKeywordToken = { retval.setExtraKeyword(extraKeywordToken.image); } ] expressionList=ExpressionList() [ orderByList = OrderByElements() { retval.setOrderByElements(orderByList); } ] @@ -11576,6 +11577,29 @@ ExpressionList FunctionArgumentList(Function retval): } } +/** XMLFOREST keeps expressions in the ordinary function parameter list. */ +ExpressionList XmlForestArguments(): +{ + ExpressionList arguments = new ExpressionList(); + Expression expression; +} +{ + expression=XmlForestArgument() { arguments.add(expression); } + ( "," expression=XmlForestArgument() { arguments.add(expression); } )* + { return arguments; } +} + +Expression XmlForestArgument(): +{ + Expression expression; + String name; +} +{ + expression=Expression() + [ name=RelObjectName() { expression = new AliasedExpression(expression, new Alias(name, true)); } ] + { return expression; } +} + XMLSerializeExpr XMLSerializeExpr(): { XMLSerializeExpr result = new XMLSerializeExpr(); Expression expression; diff --git a/src/test/java/net/sf/jsqlparser/statement/select/XmlForestTest.java b/src/test/java/net/sf/jsqlparser/statement/select/XmlForestTest.java new file mode 100644 index 000000000..c7cc4ddb0 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/select/XmlForestTest.java @@ -0,0 +1,105 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.select; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static net.sf.jsqlparser.util.validation.ValidationTestAsserts.validateNotAllowed; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.expression.AliasedExpression; +import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; +import net.sf.jsqlparser.expression.Function; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +import net.sf.jsqlparser.util.deparser.SelectDeParser; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class XmlForestTest { + @ParameterizedTest + @ValueSource(strings = {"XMLFOREST(name AS \"Name\")", "xmlforest(a, b AS item)", + "XMLFOREST(1 + 2 AS total, COALESCE(a, 'default') AS \"Value\")", + "XMLFOREST(XMLFOREST(a AS inner_name) AS outer_name)", + "XMLFOREST(name a)", "XMLFOREST(name)", "XMLPARSE(CONTENT a)"}) + void parsesAndRoundTripsNamedAndUnnamedArguments(String expression) throws Exception { + var statement = assertSqlCanBeParsedAndDeparsed("SELECT " + expression + " FROM t"); + assertEquals(statement.toString(), CCJSqlParserUtil.parse(statement.toString()).toString()); + } + + @Test + void aliasIsStructuredAndCanBeEditedWithoutReparsingSql() throws Exception { + var statement = + (PlainSelect) CCJSqlParserUtil.parse("SELECT XMLFOREST(name AS \"Name\") FROM t"); + Function function = (Function) statement.getSelectItem(0).getExpression(); + AliasedExpression argument = (AliasedExpression) function.getParameters().get(0); + assertEquals("name", ((Column) argument.getExpression()).getColumnName()); + assertEquals("\"Name\"", argument.getAlias().getName()); + argument.setExpression(new LongValue(7)); + argument.setAlias(new Alias("value", true)); + assertEquals("SELECT XMLFOREST(7 AS value) FROM t", statement.toString()); + StringBuilder output = new StringBuilder(); + statement.accept(new StatementDeParser(output), null); + assertEquals(statement.toString(), output.toString()); + } + + @Test + void customVisitorReachesValuesWithContextAndDeparserKeepsAliases() throws Exception { + var statement = (PlainSelect) CCJSqlParserUtil + .parse("SELECT XMLFOREST(1 + 2 AS total, 3 AS n) FROM t"); + List values = new ArrayList<>(); + statement.getSelectItem(0).getExpression().accept(new ExpressionVisitorAdapter() { + @Override + public Void visit(LongValue value, S context) { + assertEquals("context", context); + values.add(value.getValue()); + return null; + } + }, "context"); + assertEquals(Arrays.asList(1L, 2L, 3L), values); + StringBuilder output = new StringBuilder(); + ExpressionDeParser expressions = new ExpressionDeParser() { + @Override + public StringBuilder visit(LongValue value, S context) { + return getBuilder().append(value.getValue() + 100); + } + }; + statement.accept(new StatementDeParser(expressions, new SelectDeParser(), output), null); + assertEquals("SELECT XMLFOREST(101 + 102 AS total, 103 AS n) FROM t", output.toString()); + } + + @Test + void traversalAndValidationReachTheUnderlyingArgument() throws Exception { + assertEquals(Set.of("outer_t", "inner_t"), TablesNamesFinder.findTables( + "SELECT XMLFOREST((SELECT a FROM inner_t) AS item) FROM outer_t")); + validateNotAllowed("SELECT a FROM t WHERE XMLFOREST(? AS item) IS NULL", 1, 1, + new FeaturesAllowed().add(FeaturesAllowed.SELECT).remove(Feature.jdbcParameter), + Feature.jdbcParameter); + } + + @ParameterizedTest + @ValueSource(strings = {"SELECT XMLFOREST(a AS) FROM t", "SELECT XMLFOREST(a AS b.c) FROM t", + "SELECT f(a AS b) FROM t"}) + void rejectsMissingAliasesAndKeepsOrdinaryFunctionArgumentsStrict(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + } +}