diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index 5b33177f5..7a6396c24 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -65,6 +65,7 @@ import net.sf.jsqlparser.statement.select.PivotVisitor; import net.sf.jsqlparser.statement.select.PivotVisitorAdapter; import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.statement.select.MySqlSelectIntoClause; import net.sf.jsqlparser.statement.select.Select; import net.sf.jsqlparser.statement.select.SelectItem; import net.sf.jsqlparser.statement.select.SelectItemVisitor; @@ -795,6 +796,12 @@ public Void visit(PlainSelect plainSelect, S context) { analysis.certain.remove(StmtFeature.RETURNS_RESULT_SET); } + MySqlSelectIntoClause mySqlInto = plainSelect.getMySqlSelectIntoClause(); + if (mySqlInto != null && mySqlInto.getType() == MySqlSelectIntoClause.Type.VARIABLES) { + analysis.certain(StmtFeature.MODIFIES_SESSION); + analysis.certain.remove(StmtFeature.RETURNS_RESULT_SET); + } + if (plainSelect.getForMode() != null) { // FOR UPDATE / FOR SHARE take row locks analysis.certain(StmtFeature.MODIFIES_TRANSACTION); diff --git a/src/main/java/net/sf/jsqlparser/statement/select/MySqlSelectIntoClause.java b/src/main/java/net/sf/jsqlparser/statement/select/MySqlSelectIntoClause.java index 5c5f93bab..16a0755fc 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/MySqlSelectIntoClause.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/MySqlSelectIntoClause.java @@ -10,7 +10,11 @@ package net.sf.jsqlparser.statement.select; import java.io.Serializable; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.expression.UserVariable; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; import net.sf.jsqlparser.parser.ASTNodeAccessImpl; public class MySqlSelectIntoClause extends ASTNodeAccessImpl implements Serializable { @@ -20,7 +24,7 @@ public enum Position { } public enum Type { - OUTFILE, DUMPFILE + OUTFILE, DUMPFILE, VARIABLES } public enum FieldsKeyword { @@ -30,6 +34,7 @@ public enum FieldsKeyword { private Position position = Position.TRAILING; private Type type; private StringValue fileName; + private ExpressionList variables; private String characterSet; private FieldsKeyword fieldsKeyword; private StringValue fieldsTerminatedBy; @@ -60,6 +65,14 @@ public void setType(Type type) { this.type = type; } + public ExpressionList getVariables() { + return variables; + } + + public void setVariables(ExpressionList variables) { + this.variables = variables; + } + public StringValue getFileName() { return fileName; } @@ -142,7 +155,19 @@ public boolean hasLinesClause() { } public StringBuilder appendTo(StringBuilder builder) { - builder.append("INTO ").append(type); + return appendTo(builder, expression -> builder.append(expression)); + } + + /** Shares INTO rendering while allowing deparsers to visit variable targets. */ + public StringBuilder appendTo(StringBuilder builder, Consumer expressionRenderer) { + builder.append("INTO "); + if (type == Type.VARIABLES) { + if (variables != null) { + expressionRenderer.accept(variables); + } + return builder; + } + builder.append(type); appendFileName(builder); appendCharacterSet(builder); appendFieldsClause(builder); diff --git a/src/main/java/net/sf/jsqlparser/statement/select/SelectVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/select/SelectVisitorAdapter.java index 904b578a7..051f3929e 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/SelectVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/SelectVisitorAdapter.java @@ -164,6 +164,7 @@ public T visit(PlainSelect plainSelect, S context) { if (plainSelect.getMySqlSelectIntoClause() != null) { MySqlSelectIntoClause mySqlSelectIntoClause = plainSelect.getMySqlSelectIntoClause(); + expressionVisitor.visitExpressions(mySqlSelectIntoClause.getVariables(), context); expressionVisitor.visitExpression(mySqlSelectIntoClause.getFileName(), context); expressionVisitor.visitExpression(mySqlSelectIntoClause.getFieldsTerminatedBy(), context); diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java index 4b8e13772..c9cb4667a 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java @@ -258,11 +258,7 @@ public StringBuilder visit(PlainSelect plainSelect, S context) { } } - if (plainSelect.getMySqlSelectIntoClause() != null - && plainSelect.getMySqlSelectIntoClause() - .getPosition() == MySqlSelectIntoClause.Position.BEFORE_FROM) { - builder.append(" ").append(plainSelect.getMySqlSelectIntoClause()); - } + deparseMySqlSelectInto(plainSelect, MySqlSelectIntoClause.Position.BEFORE_FROM, context); if (plainSelect.getFromItem() != null) { builder.append(" FROM "); @@ -401,11 +397,7 @@ public StringBuilder visit(PlainSelect plainSelect, S context) { if (plainSelect.isForUpdateBeforeOrderBy()) { deparseOrderByElementsClause(plainSelect, plainSelect.getOrderByElements()); } - if (plainSelect.getMySqlSelectIntoClause() != null - && plainSelect.getMySqlSelectIntoClause() - .getPosition() == MySqlSelectIntoClause.Position.TRAILING) { - builder.append(" ").append(plainSelect.getMySqlSelectIntoClause()); - } + deparseMySqlSelectInto(plainSelect, MySqlSelectIntoClause.Position.TRAILING, context); if (plainSelect.getSettings() != null && !plainSelect.getSettings().isEmpty()) { builder.append(" SETTINGS "); deparseUpdateSets(plainSelect.getSettings(), builder, expressionVisitor); @@ -720,6 +712,15 @@ public void setExpressionVisitor(ExpressionVisitor visitor) { expressionVisitor = visitor; } + private void deparseMySqlSelectInto(PlainSelect select, + MySqlSelectIntoClause.Position position, S context) { + MySqlSelectIntoClause into = select.getMySqlSelectIntoClause(); + if (into != null && into.getPosition() == position) { + builder.append(' '); + into.appendTo(builder, expression -> expression.accept(expressionVisitor, context)); + } + } + @SuppressWarnings({"PMD.CyclomaticComplexity"}) public void deparseJoin(Join join) { if (join.isGlobal()) { diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index e89a86094..eb6c7d14e 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1420,6 +1420,20 @@ public class CCJSqlParser extends AbstractJSqlParser { return Dialect.MYSQL.name().equals(dialect) || Dialect.MARIADB.name().equals(dialect); } + private boolean isMySqlSelectIntoAhead() { + if (getToken(1).kind != K_INTO) { return false; } + int kind = getToken(2).kind; + return kind == K_OUTFILE || kind == K_DUMPFILE + || isMySqlDialect() && (kind == S_AT_IDENTIFIER || kind == K_AT_SIGN); + } + + private static UserVariable requireMySqlIntoVariable(UserVariable variable) throws ParseException { + if (variable.isDoubleAdd()) { + throw new ParseException("INTO requires a user variable with a single @ prefix"); + } + return variable; + } + private ExplainStatement.OptionType postgresqlExplainOptionType(String name) throws ParseException { try { return ExplainStatement.OptionType.from(name); @@ -6190,12 +6204,14 @@ PlainSelect PlainSelect() #PlainSelect: selectItems=SelectItemsList() - [ LOOKAHEAD( ( | )) - mySqlSelectIntoClause = MySqlSelectIntoClause(MySqlSelectIntoClause.Position.BEFORE_FROM) - { plainSelect.setMySqlSelectIntoClause(mySqlSelectIntoClause); } - ] [ LOOKAHEAD() - intoTables = IntoClause() { plainSelect.setIntoTables(intoTables); } + ( + LOOKAHEAD({ isMySqlSelectIntoAhead() }) + mySqlSelectIntoClause = MySqlSelectIntoClause(MySqlSelectIntoClause.Position.BEFORE_FROM) + { plainSelect.setMySqlSelectIntoClause(mySqlSelectIntoClause); } + | + intoTables = IntoClause() { plainSelect.setIntoTables(intoTables); } + ) ] [ LOOKAHEAD(2) fromItem=FromItem() [ LOOKAHEAD(2) lateralViews=LateralViews() ] @@ -6287,7 +6303,12 @@ PlainSelect PlainSelect() #PlainSelect: ] [ LOOKAHEAD(2) interpolateElements = InterpolateClause() { plainSelect.setInterpolate(interpolateElements); } ] ] - [ LOOKAHEAD( ( | )) + [ LOOKAHEAD({ isMySqlSelectIntoAhead() }) + { + if (mySqlSelectIntoClause != null || intoTables != null) { + throw new ParseException("Only one INTO clause is allowed per SELECT"); + } + } mySqlSelectIntoClause = MySqlSelectIntoClause(MySqlSelectIntoClause.Position.TRAILING) { plainSelect.setMySqlSelectIntoClause(mySqlSelectIntoClause); } ] @@ -6941,6 +6962,8 @@ MySqlSelectIntoClause MySqlSelectIntoClause(MySqlSelectIntoClause.Position posit { MySqlSelectIntoClause intoClause = new MySqlSelectIntoClause().withPosition(position); Token token; + ExpressionList variables = new ExpressionList(); + UserVariable variable; } { @@ -6951,12 +6974,32 @@ MySqlSelectIntoClause MySqlSelectIntoClause(MySqlSelectIntoClause.Position posit | { intoClause.setType(MySqlSelectIntoClause.Type.DUMPFILE); } token= { intoClause.setFileName(new StringValue(token.image)); } + | + LOOKAHEAD({ isMySqlDialect() }) + variable=MySqlIntoVariable() { variables.add(variable); } + ( "," variable=MySqlIntoVariable() { variables.add(variable); } )* + { intoClause.setType(MySqlSelectIntoClause.Type.VARIABLES); intoClause.setVariables(variables); } ) { return intoClause; } } +UserVariable MySqlIntoVariable(): +{ + UserVariable variable; + Token name; +} +{ + ( + variable=UserVariable() + | + (name= | name=) + { variable = new UserVariable("@" + name.image); } + ) + { return requireMySqlIntoVariable(variable); } +} + MySqlProcedureAnalyse MySqlProcedureAnalyse(): { MySqlProcedureAnalyse procedureAnalyse = new MySqlProcedureAnalyse(); diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 6d3995c0c..8eab0943c 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -716,6 +716,12 @@ One grammar covers every supported RDBMS, but a few pieces of syntax mean differ Features set explicitly *after* the preset win over it. +MySQL user-variable targets in ``SELECT ... INTO @variable`` require +``Dialect.MYSQL`` or ``Dialect.MARIADB``. They are stored in +``PlainSelect.getMySqlSelectIntoClause().getVariables()`` as ``UserVariable`` +expressions, with the clause position preserved before ``FROM`` or at the end +of the query. They are not table targets in ``getIntoTables()``. + With ``Dialect.SQLSERVER``, ``PRIMARY KEY NONCLUSTERED (id)`` and ``UNIQUE CLUSTERED (id)`` store their clustering option in ``Index.getClustering()`` for both ``CREATE TABLE`` and ``ALTER TABLE``. Without that dialect, these words diff --git a/src/test/java/net/sf/jsqlparser/statement/select/MySqlSelectIntoVariablesTest.java b/src/test/java/net/sf/jsqlparser/statement/select/MySqlSelectIntoVariablesTest.java new file mode 100644 index 000000000..41acb9321 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/select/MySqlSelectIntoVariablesTest.java @@ -0,0 +1,155 @@ +/*- + * #%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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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.UserVariable; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.StatementFeatureVisitor; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementFeatures; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.test.TestUtils; +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 org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class MySqlSelectIntoVariablesTest { + private static PlainSelect parse(String sql, Dialect dialect) throws JSQLParserException { + return (PlainSelect) CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(dialect)); + } + + @ParameterizedTest + @EnumSource(value = Dialect.class, names = {"MYSQL", "MARIADB"}) + void parsesOriginalReproducerAndPreservesTargets(Dialect dialect) throws Exception { + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed( + "SELECT COUNT(*) INTO @countTotal FROM employee", false, + parser -> parser.withDialect(dialect)); + assertNull(select.getIntoTables()); + MySqlSelectIntoClause into = select.getMySqlSelectIntoClause(); + assertEquals(MySqlSelectIntoClause.Type.VARIABLES, into.getType()); + assertEquals(MySqlSelectIntoClause.Position.BEFORE_FROM, into.getPosition()); + assertEquals("countTotal", into.getVariables().get(0).getName()); + assertEquals(Set.of("employee"), new TablesNamesFinder<>().getTables((Statement) select)); + } + + @ParameterizedTest + @EnumSource(value = Dialect.class, names = {"MYSQL", "MARIADB"}) + void preservesPositionsQuotedNamesAndRoundTrips(Dialect dialect) throws Exception { + for (String variables : List.of("@a, @b", "@`first name`, @'second name'")) { + for (String sql : List.of("SELECT a, b INTO " + variables + " FROM t", + "SELECT a, b FROM t ORDER BY a LIMIT 1 INTO " + variables, + "SELECT a, b FROM t FOR UPDATE INTO " + variables)) { + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sql, + false, parser -> parser.withDialect(dialect)); + MySqlSelectIntoClause into = select.getMySqlSelectIntoClause(); + assertEquals(sql.endsWith("FROM t") ? MySqlSelectIntoClause.Position.BEFORE_FROM + : MySqlSelectIntoClause.Position.TRAILING, into.getPosition()); + assertEquals(variables, into.getVariables().toString()); + StringBuilder output = new StringBuilder(); + select.accept(new StatementDeParser(output), null); + for (String rendered : List.of(select.toString(), output.toString())) { + MySqlSelectIntoClause reparsed = + parse(rendered, dialect).getMySqlSelectIntoClause(); + assertEquals(into.getPosition(), reparsed.getPosition()); + assertEquals(variables, reparsed.getVariables().toString()); + } + } + } + assertEquals("@value", parse("SELECT 1 INTO @value", dialect) + .getMySqlSelectIntoClause().getVariables().toString()); + } + + @Test + void requiresMySqlDialectAndKeepsTableTargets() throws Exception { + String sql = "SELECT a INTO @value FROM t"; + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + for (Dialect dialect : Dialect.values()) { + if (dialect != Dialect.MYSQL && dialect != Dialect.MARIADB) { + assertThrows(JSQLParserException.class, () -> parse(sql, dialect), dialect.name()); + } + } + PlainSelect tableInto = parse("SELECT a INTO target FROM source", Dialect.SQLSERVER); + assertNull(tableInto.getMySqlSelectIntoClause()); + assertEquals("target", tableInto.getIntoTables().get(0).getName()); + } + + @Test + void visitsAndRewritesVariables() throws Exception { + PlainSelect select = parse("SELECT a, b FROM t INTO @x, @y", Dialect.MYSQL); + List names = new ArrayList<>(); + select.accept(new SelectVisitorAdapter(new ExpressionVisitorAdapter() { + @Override + public Void visit(UserVariable variable, S context) { + names.add(variable.getName()); + return null; + } + }), null); + assertEquals(List.of("x", "y"), names); + StringBuilder output = new StringBuilder(); + ExpressionDeParser expressions = new ExpressionDeParser() { + @Override + public StringBuilder visit(UserVariable variable, S context) { + return getBuilder().append("@renamed_").append(variable.getName()); + } + }; + SelectDeParser selects = new SelectDeParser(expressions, output); + expressions.setSelectVisitor(selects); + expressions.setBuilder(output); + select.accept((SelectVisitor) selects, null); + assertEquals("SELECT a, b FROM t INTO @renamed_x, @renamed_y", output.toString()); + select.getMySqlSelectIntoClause().getVariables().get(0).setName("new_x"); + TestUtils.assertDeparse(select, "SELECT a, b FROM t INTO @new_x, @y"); + } + + @Test + void reportsSessionAssignmentInsteadOfTableCreationOrResultSet() throws Exception { + StatementFeatures features = StatementFeatureVisitor.analyse( + parse("SELECT a INTO @value FROM t", Dialect.MYSQL)); + assertTrue(features.is(StmtFeature.READS_DATA)); + assertTrue(features.is(StmtFeature.MODIFIES_SESSION)); + assertFalse(features.modifiesSchema()); + assertFalse(features.modifiesData()); + assertFalse(features.returnsResultSet()); + } + + @Test + void rejectsSystemVariablesMalformedListsAndDuplicateInto() { + for (String sql : List.of("SELECT 1 INTO @@sql_mode", "SELECT 1 INTO @", + "SELECT a, b INTO @x, FROM t", "SELECT a INTO @x + 1 FROM t", + "SELECT a INTO @x FROM t INTO @y", "SELECT a INTO @x INTO target FROM t", + "SELECT a INTO target FROM t INTO @x", + "SELECT a INTO @x FROM t INTO OUTFILE '/tmp/result'")) { + assertThrows(JSQLParserException.class, () -> parse(sql, Dialect.MYSQL), sql); + } + } + + @Test + void keepsStatementBoundaries() throws Exception { + assertEquals(2, CCJSqlParserUtil.parseStatements( + "SELECT a INTO @x FROM t; SELECT @x;", + parser -> parser.withDialect(Dialect.MYSQL)).size()); + } +}