diff --git a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java index adbe17e8dc..abc1ced8e2 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java +++ b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java @@ -10,6 +10,7 @@ package net.sf.jsqlparser.expression; import java.util.List; +import net.sf.jsqlparser.statement.execute.ExecuteArgument; import net.sf.jsqlparser.expression.operators.arithmetic.Addition; import net.sf.jsqlparser.expression.operators.arithmetic.BitwiseAnd; import net.sf.jsqlparser.expression.operators.arithmetic.BitwiseLeftShift; @@ -72,6 +73,10 @@ public interface ExpressionVisitor { + default T visit(ExecuteArgument argument, S context) { + return argument.getExpression().accept(this, context); + } + default T visitExpressions(ExpressionList expressions, S context) { if (expressions != null) { expressions.forEach(expression -> expression.accept(this, context)); diff --git a/src/main/java/net/sf/jsqlparser/statement/execute/Execute.java b/src/main/java/net/sf/jsqlparser/statement/execute/Execute.java index f29ecb2169..59bc058379 100644 --- a/src/main/java/net/sf/jsqlparser/statement/execute/Execute.java +++ b/src/main/java/net/sf/jsqlparser/statement/execute/Execute.java @@ -10,11 +10,13 @@ package net.sf.jsqlparser.statement.execute; import java.util.Locale; +import java.util.StringJoiner; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.operators.relational.ExpressionList; import net.sf.jsqlparser.expression.operators.relational.ParenthesedExpressionList; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.StatementVisitor; -import net.sf.jsqlparser.statement.select.PlainSelect; import java.util.List; @@ -33,13 +35,11 @@ public void setName(String name) { } public void setName(List names) { + StringJoiner qualifiedName = new StringJoiner("."); for (String item : names) { - if (this.name != null) { - this.name = this.name + "." + item; - } else { - this.name = item; - } + qualifiedName.add(item == null ? "" : item); } + this.name = qualifiedName.toString(); } public ExpressionList getExprList() { @@ -70,11 +70,29 @@ public T accept(StatementVisitor statementVisitor, S context) { @Override public String toString() { - return execType.name() + " " + name - + (exprList != null - ? " " + PlainSelect.getStringList(exprList, true, - exprList instanceof ParenthesedExpressionList) - : ""); + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } + + public StringBuilder appendTo(StringBuilder builder, Consumer expressionPrinter) { + builder.append(execType.name()).append(' ').append(name); + if (exprList != null) { + builder.append(' '); + boolean brackets = exprList instanceof ParenthesedExpressionList; + if (brackets) { + builder.append('('); + } + for (int i = 0; i < exprList.size(); i++) { + if (i > 0) { + builder.append(", "); + } + expressionPrinter.accept((Expression) exprList.get(i)); + } + if (brackets) { + builder.append(')'); + } + } + return builder; } public Execute withExecType(ExecType execType) { diff --git a/src/main/java/net/sf/jsqlparser/statement/execute/ExecuteArgument.java b/src/main/java/net/sf/jsqlparser/statement/execute/ExecuteArgument.java new file mode 100644 index 0000000000..9ed550a6e4 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/execute/ExecuteArgument.java @@ -0,0 +1,62 @@ +/*- + * #%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.execute; + +import java.util.Objects; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.ExpressionVisitor; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; + +/** An EXEC argument with a call-specific OUTPUT modifier. */ +public class ExecuteArgument extends ASTNodeAccessImpl implements Expression { + private Expression expression; + private boolean output; + + public ExecuteArgument(Expression expression, boolean output) { + setExpression(expression); + this.output = output; + } + + public Expression getExpression() { + return expression; + } + + public void setExpression(Expression expression) { + this.expression = Objects.requireNonNull(expression, "expression"); + } + + public boolean isOutput() { + return output; + } + + public void setOutput(boolean output) { + this.output = output; + } + + @Override + public T accept(ExpressionVisitor visitor, S context) { + return visitor.visit(this, context); + } + + public StringBuilder appendTo(StringBuilder builder, Consumer expressionPrinter) { + expressionPrinter.accept(expression); + if (output) { + builder.append(" OUTPUT"); + } + 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/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 34270bc3a3..9ffa37da92 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -129,6 +129,7 @@ import net.sf.jsqlparser.statement.delete.ParenthesedDelete; import net.sf.jsqlparser.statement.drop.Drop; import net.sf.jsqlparser.statement.execute.Execute; +import net.sf.jsqlparser.statement.execute.ExecuteArgument; import net.sf.jsqlparser.statement.export.Export; import net.sf.jsqlparser.statement.grant.Grant; import net.sf.jsqlparser.statement.imprt.Import; @@ -1696,6 +1697,11 @@ public Void visit(Execute execute, S context) { return null; } + @Override + public Void visit(ExecuteArgument argument, S context) { + return argument.getExpression().accept(this, context); + } + @Override public void visit(Execute execute) { StatementVisitor.super.visit(execute); diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/ExecuteDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/ExecuteDeParser.java index 4034bd152d..a4292b3802 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ExecuteDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ExecuteDeParser.java @@ -9,9 +9,7 @@ */ package net.sf.jsqlparser.util.deparser; -import java.util.List; -import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.ExpressionVisitor; import net.sf.jsqlparser.statement.execute.Execute; @@ -27,24 +25,7 @@ public ExecuteDeParser(ExpressionVisitor expressionVisitor, @Override public void deParse(Execute execute) { - builder.append(execute.getExecType().name()).append(" ").append(execute.getName()); - if (execute.isParenthesis()) { - builder.append(" ("); - } else if (execute.getExprList() != null) { - builder.append(" "); - } - if (execute.getExprList() != null) { - List expressions = execute.getExprList().getExpressions(); - for (int i = 0; i < expressions.size(); i++) { - if (i > 0) { - builder.append(", "); - } - expressions.get(i).accept(expressionVisitor, null); - } - } - if (execute.isParenthesis()) { - builder.append(")"); - } + execute.appendTo(builder, expression -> expression.accept(expressionVisitor, null)); } public ExpressionVisitor getExpressionVisitor() { 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 17ab206d65..fc281f62f8 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java @@ -1419,6 +1419,12 @@ public StringBuilder visit(UserVariable var, S context) { return builder; } + @Override + public StringBuilder visit( + net.sf.jsqlparser.statement.execute.ExecuteArgument argument, S context) { + return argument.appendTo(builder, expression -> expression.accept(this, context)); + } + @Override public StringBuilder visit(NumericBind bind, S context) { builder.append(bind.toString()); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 860ab3821c..d46d9415b1 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -126,6 +126,21 @@ public class CCJSqlParser extends AbstractJSqlParser { // and must not be taken as the JSON path operator private int mapKeyDepth = 0; + private Expression createOutputArgument(Expression argument) throws ParseException { + Expression value = argument; + if (argument instanceof VariableAssignment) { + value = ((VariableAssignment) argument).getExpression(); + } else if (argument instanceof EqualsTo + && ((EqualsTo) argument).getLeftExpression() instanceof UserVariable) { + value = ((EqualsTo) argument).getRightExpression(); + } + if (!(value instanceof UserVariable || value instanceof JdbcParameter + || value instanceof JdbcNamedParameter)) { + throw new ParseException("An OUTPUT argument must be a variable or bind parameter"); + } + return new net.sf.jsqlparser.statement.execute.ExecuteArgument(argument, true); + } + private void linkAST(ASTNodeAccess access, Node node) { access.setASTNode(node); node.jjtSetValue(access); @@ -10654,27 +10669,48 @@ EqualsTo VariableExpression(): { } Execute Execute(): { - Token token; ObjectNames funcName; - ExpressionList expressionList = null; + ExpressionList expressionList; Execute execute = new Execute(); - List namedExprList; - Expression expr; } { ( { execute.setExecType(Execute.ExecType.EXEC); } | { execute.setExecType(Execute.ExecType.EXECUTE); } | { execute.setExecType(Execute.ExecType.CALL); } ) - funcName=RelObjectNames() { execute.setName(funcName.getNames()); } + // A colon starts a bind argument here; it must not join the procedure name. + funcName=ColumnIdentifier() { execute.setName(funcName.getNames()); } + [ LOOKAHEAD(2) expressionList=ExecuteArguments() { execute.setExprList(expressionList); } ] + { return execute; } +} +ExpressionList ExecuteArguments(): { + ExpressionList arguments = new ExpressionList(); + Expression argument; +} +{ ( - LOOKAHEAD(2) expressionList=ExpressionList() { execute.setExprList(expressionList); } - )? + LOOKAHEAD("(") arguments=ExpressionList() + | + argument=ExecuteArgument() { arguments.add(argument); } + ( "," argument=ExecuteArgument() { arguments.add(argument); } )* + ) + { return arguments; } +} + +Expression ExecuteArgument(): { + Expression argument; + boolean output = false; +} +{ + argument=Expression() + [ + LOOKAHEAD({ isAccessKeywordAhead("OUTPUT") || isAccessKeywordAhead("OUT") }) + ( | AccessKeyword("OUT") ) + { output = true; } + ] + { return output ? createOutputArgument(argument) : argument; } - { - return execute; - } } FullTextSearch FullTextSearch() : { diff --git a/src/test/java/net/sf/jsqlparser/statement/execute/ExecuteArgumentsTest.java b/src/test/java/net/sf/jsqlparser/statement/execute/ExecuteArgumentsTest.java new file mode 100644 index 0000000000..c303d436f9 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/execute/ExecuteArgumentsTest.java @@ -0,0 +1,140 @@ +/*- + * #%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.execute; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; +import net.sf.jsqlparser.expression.JdbcNamedParameter; +import net.sf.jsqlparser.expression.UserVariable; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +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 ExecuteArgumentsTest { + private Execute roundtrip(String sql) throws JSQLParserException { + Execute execute = (Execute) CCJSqlParserUtil.parse(sql, + p -> p.withSquareBracketQuotation(true)); + StringBuilder text = new StringBuilder(); + execute.accept(new StatementDeParser(text), null); + assertThat(text.toString()).isEqualTo(execute.toString()); + assertThat(CCJSqlParserUtil.parse(text.toString(), p -> p.withSquareBracketQuotation(true)) + .toString()).isEqualTo(execute.toString()); + return execute; + } + + @ParameterizedTest + @ValueSource(strings = {"EXEC", "EXECUTE", "CALL"}) + void namedBindsRemainArguments(String command) throws JSQLParserException { + for (String name : List.of("PCK_ACTION_BY", "dbo.p", "db..p", "srv.db.dbo.p", + "\"a.b\".\"p\"", "[dbo].[p]")) { + Execute execute = roundtrip(command + " " + name + " :USER_ID, :GROUP_ID"); + assertThat(execute.getName()).isEqualTo(name); + assertThat(execute.getExprList()).hasSize(2); + assertThat(execute.getExprList().get(0)).isInstanceOf(JdbcNamedParameter.class); + assertThat(((JdbcNamedParameter) execute.getExprList().get(0)).getName()) + .isEqualTo("USER_ID"); + } + } + + @ParameterizedTest + @ValueSource(strings = {"EXECUTE myProc 'foo', @outputVar OUTPUT", + "EXEC p @result = @value OUTPUT, :other", "EXEC p @value OUT", + "EXEC p :value OUTPUT", "EXEC p ? OUTPUT"}) + void outputArgumentsHaveTheirOwnModel(String sql) throws JSQLParserException { + Execute execute = roundtrip(sql); + assertThat(execute.getExprList()).anySatisfy(expression -> { + assertThat(expression).isInstanceOf(ExecuteArgument.class); + assertThat(((ExecuteArgument) expression).isOutput()).isTrue(); + }); + assertThat(Validation.validate(List.of(new FeaturesAllowed(Feature.values())), sql)) + .isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"CALL p ()", "CALL p (:a, :b)", "EXEC p (1 + 2) * 3", + "EXEC p @param = 'foo', @param2 = 'bar'", "CALL dbo.p", "EXEC p -1, NULL"}) + void legacyArgumentFormsStillRoundtrip(String sql) throws JSQLParserException { + roundtrip(sql); + } + + @Test + void preservesFollowingStatementsAndVisitsOutputVariables() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "EXEC p @a OUTPUT, @b = @c OUTPUT; SELECT 42;"); + assertThat(statements).hasSize(2); + assertThat(statements.get(1).toString()).isEqualTo("SELECT 42"); + Execute execute = (Execute) statements.get(0); + List variables = new ArrayList<>(); + Object marker = new Object(); + execute.getExprList().accept(new ExpressionVisitorAdapter() { + @Override + public Void visit(UserVariable variable, S context) { + assertThat(context).isSameAs(marker); + variables.add(variable.getName()); + return null; + } + }, marker); + assertThat(variables).containsExactly("a", "b", "c"); + variables.clear(); + execute.getExprList().accept(new net.sf.jsqlparser.util.TablesNamesFinder() { + @Override + public Void visit(UserVariable variable, S context) { + assertThat(context).isSameAs(marker); + variables.add(variable.getName()); + return null; + } + }, marker); + assertThat(variables).containsExactly("a", "b", "c"); + } + + @Test + void customDeparserRetainsTheOutputModifierAndLegacyMutation() throws JSQLParserException { + Execute execute = roundtrip("EXEC p @a OUTPUT"); + StringBuilder text = new StringBuilder(); + ExpressionDeParser expressions = new ExpressionDeParser() { + @Override + public StringBuilder visit(UserVariable variable, S context) { + getBuilder().append('@').append(variable.getName()).append("_rewritten"); + return getBuilder(); + } + }; + expressions.setBuilder(text); + new net.sf.jsqlparser.util.deparser.ExecuteDeParser(expressions, text).deParse(execute); + assertThat(text.toString()).isEqualTo("EXEC p @a_rewritten OUTPUT"); + + ExecuteArgument argument = (ExecuteArgument) execute.getExprList().get(0); + argument.setOutput(false); + argument.setExpression(new JdbcNamedParameter().withName("replacement")); + execute.setExprList(new ExpressionList<>(argument)); + execute.setName(Arrays.asList("db", null, "renamed")); + assertThat(execute.toString()).isEqualTo("EXEC db..renamed :replacement"); + } + + @ParameterizedTest + @ValueSource(strings = {"EXEC p 1 OUTPUT", "EXEC p 'x' OUT", "EXEC p @x = 1 OUTPUT", + "EXEC p @x OUTPUT OUTPUT", "EXEC p :id,"}) + void rejectsMalformedOutputArguments(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + } +}