Skip to content

Commit 14aedc0

Browse files
committed
Support SQL Server XML methods on subquery results
1 parent a206b86 commit 14aedc0

7 files changed

Lines changed: 234 additions & 4 deletions

File tree

src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,15 @@ default void visit(RowConstructor<? extends Expression> rowConstructor) {
569569

570570
<S> T visit(RowGetExpression rowGetExpression, S context);
571571

572+
default <S> T visit(MethodCallExpression methodCall, S context) {
573+
methodCall.getExpression().accept(this, context);
574+
return methodCall.getMethod().accept(this, context);
575+
}
576+
577+
default void visit(MethodCallExpression methodCall) {
578+
this.visit(methodCall, null);
579+
}
580+
572581
default void visit(RowGetExpression rowGetExpression) {
573582
this.visit(rowGetExpression, null);
574583
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*-
2+
* #%L
3+
* JSQLParser library
4+
* %%
5+
* Copyright (C) 2004 - 2026 JSQLParser
6+
* %%
7+
* Dual licensed under GNU LGPL 2.1 or Apache License 2.0
8+
* #L%
9+
*/
10+
package net.sf.jsqlparser.expression;
11+
12+
import java.util.function.Consumer;
13+
import net.sf.jsqlparser.parser.ASTNodeAccessImpl;
14+
15+
/** A method applied to an expression, such as a SQL Server XML subquery result. */
16+
public class MethodCallExpression extends ASTNodeAccessImpl implements Expression {
17+
private Expression expression;
18+
private Function method;
19+
20+
public MethodCallExpression(Expression expression, Function method) {
21+
this.expression = expression;
22+
this.method = method;
23+
}
24+
25+
public Expression getExpression() {
26+
return expression;
27+
}
28+
29+
public void setExpression(Expression expression) {
30+
this.expression = expression;
31+
}
32+
33+
public Function getMethod() {
34+
return method;
35+
}
36+
37+
public void setMethod(Function method) {
38+
this.method = method;
39+
}
40+
41+
public StringBuilder appendTo(StringBuilder builder, Consumer<Expression> printer) {
42+
printer.accept(expression);
43+
builder.append('.');
44+
printer.accept(method);
45+
return builder;
46+
}
47+
48+
@Override
49+
public <T, S> T accept(ExpressionVisitor<T> visitor, S context) {
50+
return visitor.visit(this, context);
51+
}
52+
53+
@Override
54+
public String toString() {
55+
StringBuilder builder = new StringBuilder();
56+
return appendTo(builder, value -> builder.append(value)).toString();
57+
}
58+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1774,6 +1774,11 @@ public <S> Void visit(RowGetExpression rowGetExpression, S context) {
17741774
return null;
17751775
}
17761776

1777+
@Override
1778+
public <S> Void visit(MethodCallExpression methodCall, S context) {
1779+
return ExpressionVisitor.super.visit(methodCall, context);
1780+
}
1781+
17771782
@Override
17781783
public <S> Void visit(HexValue hexValue, S context) {
17791784
return null;

src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
import net.sf.jsqlparser.expression.RangeExpression;
6565
import net.sf.jsqlparser.expression.RowConstructor;
6666
import net.sf.jsqlparser.expression.RowGetExpression;
67+
import net.sf.jsqlparser.expression.MethodCallExpression;
6768
import net.sf.jsqlparser.expression.SignedExpression;
6869
import net.sf.jsqlparser.expression.StringValue;
6970
import net.sf.jsqlparser.expression.StructType;
@@ -1471,6 +1472,11 @@ public <S> StringBuilder visit(RowGetExpression rowGetExpression, S context) {
14711472
return null;
14721473
}
14731474

1475+
@Override
1476+
public <S> StringBuilder visit(MethodCallExpression methodCall, S context) {
1477+
return methodCall.appendTo(builder, expression -> expression.accept(this, context));
1478+
}
1479+
14741480
@Override
14751481
public <S> StringBuilder visit(OracleHint hint, S context) {
14761482
builder.append(hint.toString());

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9180,10 +9180,17 @@ Expression NavigationStep(Expression base):
91809180
{
91819181
Expression step = null;
91829182
String nm = null;
9183+
Function method;
91839184
}
91849185
{
91859186
(
9186-
"." nm=RelObjectNameExt() { step = new RowGetExpression(base, nm); }
9187+
"."
9188+
(
9189+
LOOKAHEAD({ Dialect.SQLSERVER.name().equals(getAsString(Feature.dialect)) && isFunctionAhead() })
9190+
method=Function() { step = new MethodCallExpression(base, method); }
9191+
|
9192+
nm=RelObjectNameExt() { step = new RowGetExpression(base, nm); }
9193+
)
91879194
|
91889195
step = ArrayExpression(base)
91899196
)
@@ -9201,7 +9208,6 @@ Expression PrimaryExpression() #PrimaryExpression:
92019208
Token token = null;
92029209
Token sign = null;
92039210
Token adjacentToken = null;
9204-
String tmp = "";
92059211
ColDataType type = null;
92069212
boolean not = false;
92079213
boolean exclamationMarkNot = false;
@@ -9354,8 +9360,8 @@ Expression PrimaryExpression() #PrimaryExpression:
93549360
"." "*"
93559361
{ retval = new FunctionAllColumns(unwrapParenthesedFunction(retval)); } ]
93569362

9357-
// RowGet Expressions
9358-
( LOOKAHEAD(2) "." tmp=RelObjectName() { retval = new RowGetExpression(retval, tmp); } )*
9363+
// Share field and method navigation with the other primary expressions.
9364+
( LOOKAHEAD(".") nxt=NavigationStep(retval) { retval = nxt; } )*
93599365
)
93609366
)
93619367

src/site/sphinx/usage.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,13 @@ uses the existing ``Update`` model's ``fromItem`` and ``joins`` properties.
766766
Table discovery and metadata validation recognize a target alias declared in
767767
that FROM clause. Other dialects retain the existing FROM-after-SET syntax.
768768

769+
``Dialect.SQLSERVER`` supports methods on expression results, including
770+
``(SELECT ... FOR XML PATH(''), TYPE).value('.', 'varchar(max)')``.
771+
``MethodCallExpression`` exposes the receiver expression and a ``Function``
772+
containing the method name and arguments. Field access and method calls share
773+
the navigation grammar; expression visitors and deparsers traverse both the
774+
receiver and method arguments. XQuery strings remain string literals.
775+
769776
With ``Dialect.SQLSERVER``, ``PRIMARY KEY NONCLUSTERED (id)`` and
770777
``UNIQUE CLUSTERED (id)`` store their clustering option in ``Index.getClustering()``
771778
for both ``CREATE TABLE`` and ``ALTER TABLE``. Without that dialect, these words
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*-
2+
* #%L
3+
* JSQLParser library
4+
* %%
5+
* Copyright (C) 2004 - 2026 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+
import static org.junit.jupiter.api.Assertions.*;
14+
15+
import java.util.ArrayList;
16+
import java.util.List;
17+
import java.util.Set;
18+
import net.sf.jsqlparser.JSQLParserException;
19+
import net.sf.jsqlparser.expression.ExpressionVisitorAdapter;
20+
import net.sf.jsqlparser.expression.Function;
21+
import net.sf.jsqlparser.expression.MethodCallExpression;
22+
import net.sf.jsqlparser.expression.StringValue;
23+
import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect;
24+
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
25+
import net.sf.jsqlparser.parser.feature.Feature;
26+
import net.sf.jsqlparser.parser.feature.FeatureConfiguration;
27+
import net.sf.jsqlparser.statement.Statement;
28+
import net.sf.jsqlparser.statement.StatementVisitorAdapter;
29+
import net.sf.jsqlparser.util.TablesNamesFinder;
30+
import net.sf.jsqlparser.util.deparser.ExpressionDeParser;
31+
import net.sf.jsqlparser.util.deparser.SelectDeParser;
32+
import net.sf.jsqlparser.util.deparser.StatementDeParser;
33+
import net.sf.jsqlparser.util.validation.Validation;
34+
import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed;
35+
import org.junit.jupiter.api.Test;
36+
import org.junit.jupiter.params.ParameterizedTest;
37+
import org.junit.jupiter.params.provider.ValueSource;
38+
39+
class SqlServerXmlMethodTest {
40+
private static final String SIMPLE =
41+
"SELECT (SELECT body FROM docs FOR XML PATH (''), TYPE).value('.', 'varchar(max)')";
42+
43+
private static PlainSelect parse(String sql) throws Exception {
44+
return (PlainSelect) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.SQLSERVER));
45+
}
46+
47+
@Test
48+
void parsesOriginalStuffQueryIssue386() throws Exception {
49+
String sql = "SELECT (STUFF((SELECT '|' + person_name FROM person "
50+
+ "JOIN person_group ON person.person_id = person_group.person_id "
51+
+ "WHERE person_group.group_id = 1 FOR XML PATH(''), TYPE)"
52+
+ ".value('.', 'varchar(max)'), 1, 1, '')) AS person_name";
53+
Statement statement = assertSqlCanBeParsedAndDeparsed(sql, true,
54+
p -> p.withDialect(Dialect.SQLSERVER));
55+
assertEquals(Set.of("person", "person_group"),
56+
new TablesNamesFinder().getTables(statement));
57+
assertEquals(statement.toString(), parse(statement.toString()).toString());
58+
}
59+
60+
@ParameterizedTest
61+
@ValueSource(strings = {"value('.', 'varchar(max)')", "query('/root')", "exist('/root')",
62+
"query('/root').value('.', 'int')"})
63+
void preservesMethodsAndChains(String method) throws Exception {
64+
String sql = "SELECT (SELECT body FROM docs FOR XML PATH(''), TYPE)." + method;
65+
PlainSelect select = (PlainSelect) assertSqlCanBeParsedAndDeparsed(sql, true,
66+
p -> p.withDialect(Dialect.SQLSERVER));
67+
MethodCallExpression call = (MethodCallExpression) select.getSelectItem(0).getExpression();
68+
assertInstanceOf(ParenthesedSelect.class, call.getExpression());
69+
assertEquals(method.substring(0, method.indexOf('(')), call.getMethod().getName());
70+
assertEquals(select.toString(), parse(select.toString()).toString());
71+
assertEquals(Set.of("docs"), new TablesNamesFinder().getTables((Statement) select));
72+
}
73+
74+
@Test
75+
void visitsReceiverAndMethodArgumentsWithContext() throws Exception {
76+
List<String> values = new ArrayList<>();
77+
ExpressionVisitorAdapter<Void> expressions = new ExpressionVisitorAdapter<Void>() {
78+
@Override
79+
public <S> Void visit(StringValue value, S context) {
80+
assertEquals("context", context);
81+
values.add(value.getValue());
82+
return null;
83+
}
84+
};
85+
SelectVisitorAdapter<Void> selects = new SelectVisitorAdapter<>(expressions);
86+
expressions.setSelectVisitor(selects);
87+
parse(SIMPLE).accept(new StatementVisitorAdapter<>(selects), "context");
88+
assertTrue(values.contains("."));
89+
assertTrue(values.contains("varchar(max)"));
90+
assertEquals(1, values.stream().filter("."::equals).count());
91+
}
92+
93+
@Test
94+
void editsAndDeparsesMethodArguments() throws Exception {
95+
PlainSelect select = parse(SIMPLE);
96+
MethodCallExpression call = (MethodCallExpression) select.getSelectItem(0).getExpression();
97+
call.setMethod(new Function("query", new StringValue("'/root'")));
98+
StringBuilder output = new StringBuilder();
99+
ExpressionDeParser expressions = new ExpressionDeParser() {
100+
@Override
101+
public <S> StringBuilder visit(StringValue value, S context) {
102+
return getBuilder().append("'changed'");
103+
}
104+
};
105+
select.accept(new StatementDeParser(expressions, new SelectDeParser(), output));
106+
assertTrue(output.toString().endsWith(".query('changed')"));
107+
assertTrue(select.toString().endsWith(".query('/root')"));
108+
assertEquals(output.toString(), parse(output.toString()).toString());
109+
}
110+
111+
@Test
112+
void gatesDialectAndRetainsRowNavigation() throws Exception {
113+
assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(SIMPLE));
114+
for (Dialect dialect : Dialect.values()) {
115+
if (dialect != Dialect.SQLSERVER) {
116+
assertThrows(JSQLParserException.class,
117+
() -> CCJSqlParserUtil.parse(SIMPLE, p -> p.withDialect(dialect)));
118+
}
119+
}
120+
assertSqlCanBeParsedAndDeparsed("SELECT (row_value).field FROM t");
121+
assertSqlCanBeParsedAndDeparsed("SELECT (row_value).field COLLATE en_US FROM t");
122+
assertThrows(JSQLParserException.class,
123+
() -> parse(SIMPLE.substring(0, SIMPLE.length() - 1)));
124+
assertEquals(2, CCJSqlParserUtil.parseStatements(SIMPLE + "; SELECT 1;",
125+
p -> p.withDialect(Dialect.SQLSERVER)).size());
126+
}
127+
128+
@Test
129+
void validatesMethodAsFunction() {
130+
String sql = "SELECT 1 WHERE " + SIMPLE.substring("SELECT ".length()) + " = 'value'";
131+
FeatureConfiguration config = new FeatureConfiguration().setValue(Feature.dialect,
132+
Dialect.SQLSERVER.name());
133+
assertTrue(new Validation(config, List.of(new FeaturesAllowed(Feature.values())), sql)
134+
.validate().isEmpty());
135+
assertFalse(new Validation(config,
136+
List.of(new FeaturesAllowed(Feature.values()).remove(Feature.function)), sql)
137+
.validate().isEmpty());
138+
}
139+
}

0 commit comments

Comments
 (0)