diff --git a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java index abc1ced8e..589f5b195 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java +++ b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java @@ -569,6 +569,15 @@ default void visit(RowConstructor rowConstructor) { T visit(RowGetExpression rowGetExpression, S context); + default T visit(MethodCallExpression methodCall, S context) { + methodCall.getExpression().accept(this, context); + return methodCall.getMethod().accept(this, context); + } + + default void visit(MethodCallExpression methodCall) { + this.visit(methodCall, null); + } + default void visit(RowGetExpression rowGetExpression) { this.visit(rowGetExpression, null); } diff --git a/src/main/java/net/sf/jsqlparser/expression/MethodCallExpression.java b/src/main/java/net/sf/jsqlparser/expression/MethodCallExpression.java new file mode 100644 index 000000000..30fb79889 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/expression/MethodCallExpression.java @@ -0,0 +1,58 @@ +/*- + * #%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; + +/** A method applied to an expression, such as a SQL Server XML subquery result. */ +public class MethodCallExpression extends ASTNodeAccessImpl implements Expression { + private Expression expression; + private Function method; + + public MethodCallExpression(Expression expression, Function method) { + this.expression = expression; + this.method = method; + } + + public Expression getExpression() { + return expression; + } + + public void setExpression(Expression expression) { + this.expression = expression; + } + + public Function getMethod() { + return method; + } + + public void setMethod(Function method) { + this.method = method; + } + + public StringBuilder appendTo(StringBuilder builder, Consumer printer) { + printer.accept(expression); + builder.append('.'); + printer.accept(method); + return builder; + } + + @Override + public T accept(ExpressionVisitor visitor, S context) { + return visitor.visit(this, context); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, value -> builder.append(value)).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 18c12c1ba..3c596371b 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -1781,6 +1781,11 @@ public Void visit(RowGetExpression rowGetExpression, S context) { return null; } + @Override + public Void visit(MethodCallExpression methodCall, S context) { + return ExpressionVisitor.super.visit(methodCall, context); + } + @Override public Void visit(HexValue hexValue, S context) { return null; 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 4a17272cc..e2fc723d3 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java @@ -64,6 +64,7 @@ import net.sf.jsqlparser.expression.RangeExpression; import net.sf.jsqlparser.expression.RowConstructor; import net.sf.jsqlparser.expression.RowGetExpression; +import net.sf.jsqlparser.expression.MethodCallExpression; import net.sf.jsqlparser.expression.SignedExpression; import net.sf.jsqlparser.expression.StringValue; import net.sf.jsqlparser.expression.StructType; @@ -1471,6 +1472,11 @@ public StringBuilder visit(RowGetExpression rowGetExpression, S context) { return null; } + @Override + public StringBuilder visit(MethodCallExpression methodCall, S context) { + return methodCall.appendTo(builder, expression -> expression.accept(this, context)); + } + @Override public StringBuilder visit(OracleHint hint, S context) { builder.append(hint.toString()); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index e83ff8775..1720fff54 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -9221,10 +9221,17 @@ Expression NavigationStep(Expression base): { Expression step = null; String nm = null; + Function method; } { ( - "." nm=RelObjectNameExt() { step = new RowGetExpression(base, nm); } + "." + ( + LOOKAHEAD({ Dialect.SQLSERVER.name().equals(getAsString(Feature.dialect)) && isFunctionAhead() }) + method=Function() { step = new MethodCallExpression(base, method); } + | + nm=RelObjectNameExt() { step = new RowGetExpression(base, nm); } + ) | step = ArrayExpression(base) ) @@ -9242,7 +9249,6 @@ Expression PrimaryExpression() #PrimaryExpression: Token token = null; Token sign = null; Token adjacentToken = null; - String tmp = ""; ColDataType type = null; boolean not = false; boolean exclamationMarkNot = false; @@ -9395,8 +9401,8 @@ Expression PrimaryExpression() #PrimaryExpression: "." "*" { retval = new FunctionAllColumns(unwrapParenthesedFunction(retval)); } ] - // RowGet Expressions - ( LOOKAHEAD(2) "." tmp=RelObjectName() { retval = new RowGetExpression(retval, tmp); } )* + // Share field and method navigation with the other primary expressions. + ( LOOKAHEAD(".") nxt=NavigationStep(retval) { retval = nxt; } )* ) ) diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 784167ca4..b77dc87e4 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -766,6 +766,13 @@ uses the existing ``Update`` model's ``fromItem`` and ``joins`` properties. Table discovery and metadata validation recognize a target alias declared in that FROM clause. Other dialects retain the existing FROM-after-SET syntax. +``Dialect.SQLSERVER`` supports methods on expression results, including +``(SELECT ... FOR XML PATH(''), TYPE).value('.', 'varchar(max)')``. +``MethodCallExpression`` exposes the receiver expression and a ``Function`` +containing the method name and arguments. Field access and method calls share +the navigation grammar; expression visitors and deparsers traverse both the +receiver and method arguments. XQuery strings remain string literals. + ``Dialect.POSTGRESQL`` enables ``DO [LANGUAGE name] code [LANGUAGE name]``, with the language clause allowed once, before or after the body. ``DoStatement.getCode()`` is a ``StringValue`` that preserves the literal's diff --git a/src/test/java/net/sf/jsqlparser/statement/select/SqlServerXmlMethodTest.java b/src/test/java/net/sf/jsqlparser/statement/select/SqlServerXmlMethodTest.java new file mode 100644 index 000000000..eabfb60b4 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/select/SqlServerXmlMethodTest.java @@ -0,0 +1,139 @@ +/*- + * #%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 net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; +import net.sf.jsqlparser.expression.Function; +import net.sf.jsqlparser.expression.MethodCallExpression; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.parser.feature.FeatureConfiguration; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitorAdapter; +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.Validation; +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 SqlServerXmlMethodTest { + private static final String SIMPLE = + "SELECT (SELECT body FROM docs FOR XML PATH (''), TYPE).value('.', 'varchar(max)')"; + + private static PlainSelect parse(String sql) throws Exception { + return (PlainSelect) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.SQLSERVER)); + } + + @Test + void parsesOriginalStuffQueryIssue386() throws Exception { + String sql = "SELECT (STUFF((SELECT '|' + person_name FROM person " + + "JOIN person_group ON person.person_id = person_group.person_id " + + "WHERE person_group.group_id = 1 FOR XML PATH(''), TYPE)" + + ".value('.', 'varchar(max)'), 1, 1, '')) AS person_name"; + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, true, + p -> p.withDialect(Dialect.SQLSERVER)); + assertEquals(Set.of("person", "person_group"), + new TablesNamesFinder().getTables(statement)); + assertEquals(statement.toString(), parse(statement.toString()).toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"value('.', 'varchar(max)')", "query('/root')", "exist('/root')", + "query('/root').value('.', 'int')"}) + void preservesMethodsAndChains(String method) throws Exception { + String sql = "SELECT (SELECT body FROM docs FOR XML PATH(''), TYPE)." + method; + PlainSelect select = (PlainSelect) assertSqlCanBeParsedAndDeparsed(sql, true, + p -> p.withDialect(Dialect.SQLSERVER)); + MethodCallExpression call = (MethodCallExpression) select.getSelectItem(0).getExpression(); + assertInstanceOf(ParenthesedSelect.class, call.getExpression()); + assertEquals(method.substring(0, method.indexOf('(')), call.getMethod().getName()); + assertEquals(select.toString(), parse(select.toString()).toString()); + assertEquals(Set.of("docs"), new TablesNamesFinder().getTables((Statement) select)); + } + + @Test + void visitsReceiverAndMethodArgumentsWithContext() throws Exception { + List values = new ArrayList<>(); + ExpressionVisitorAdapter expressions = new ExpressionVisitorAdapter() { + @Override + public Void visit(StringValue value, S context) { + assertEquals("context", context); + values.add(value.getValue()); + return null; + } + }; + SelectVisitorAdapter selects = new SelectVisitorAdapter<>(expressions); + expressions.setSelectVisitor(selects); + parse(SIMPLE).accept(new StatementVisitorAdapter<>(selects), "context"); + assertTrue(values.contains(".")); + assertTrue(values.contains("varchar(max)")); + assertEquals(1, values.stream().filter("."::equals).count()); + } + + @Test + void editsAndDeparsesMethodArguments() throws Exception { + PlainSelect select = parse(SIMPLE); + MethodCallExpression call = (MethodCallExpression) select.getSelectItem(0).getExpression(); + call.setMethod(new Function("query", new StringValue("'/root'"))); + StringBuilder output = new StringBuilder(); + ExpressionDeParser expressions = new ExpressionDeParser() { + @Override + public StringBuilder visit(StringValue value, S context) { + return getBuilder().append("'changed'"); + } + }; + select.accept(new StatementDeParser(expressions, new SelectDeParser(), output)); + assertTrue(output.toString().endsWith(".query('changed')")); + assertTrue(select.toString().endsWith(".query('/root')")); + assertEquals(output.toString(), parse(output.toString()).toString()); + } + + @Test + void gatesDialectAndRetainsRowNavigation() throws Exception { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(SIMPLE)); + for (Dialect dialect : Dialect.values()) { + if (dialect != Dialect.SQLSERVER) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(SIMPLE, p -> p.withDialect(dialect))); + } + } + assertSqlCanBeParsedAndDeparsed("SELECT (row_value).field FROM t"); + assertSqlCanBeParsedAndDeparsed("SELECT (row_value).field COLLATE en_US FROM t"); + assertThrows(JSQLParserException.class, + () -> parse(SIMPLE.substring(0, SIMPLE.length() - 1))); + assertEquals(2, CCJSqlParserUtil.parseStatements(SIMPLE + "; SELECT 1;", + p -> p.withDialect(Dialect.SQLSERVER)).size()); + } + + @Test + void validatesMethodAsFunction() { + String sql = "SELECT 1 WHERE " + SIMPLE.substring("SELECT ".length()) + " = 'value'"; + FeatureConfiguration config = new FeatureConfiguration().setValue(Feature.dialect, + Dialect.SQLSERVER.name()); + assertTrue(new Validation(config, List.of(new FeaturesAllowed(Feature.values())), sql) + .validate().isEmpty()); + assertFalse(new Validation(config, + List.of(new FeaturesAllowed(Feature.values()).remove(Feature.function)), sql) + .validate().isEmpty()); + } +}