From ab2819a3afb0e389bc12daeb58f7dfa13aed9baf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 16:07:50 +0000 Subject: [PATCH] feat: add ForUpdateClause class with multi-table and ORDER BY support Agent-Logs-Url: https://github.com/rrobetti/JSqlParser/sessions/1a91a42d-ed37-490e-88b9-25c45e621b64 Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../statement/select/ForUpdateClause.java | 135 ++++++++++++++++++ .../jsqlparser/statement/select/Select.java | 105 +++++++++++++- .../util/deparser/SelectDeParser.java | 19 ++- .../validation/validator/SelectValidator.java | 5 + .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 10 +- .../statement/select/ForUpdateTest.java | 101 +++++++++++++ .../statement/select/SpecialOracleTest.java | 1 + .../select/oracle-tests/for_update07.sql | 3 +- .../select/oracle-tests/for_update08.sql | 3 +- 9 files changed, 370 insertions(+), 12 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/select/ForUpdateClause.java diff --git a/src/main/java/net/sf/jsqlparser/statement/select/ForUpdateClause.java b/src/main/java/net/sf/jsqlparser/statement/select/ForUpdateClause.java new file mode 100644 index 000000000..499324551 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/select/ForUpdateClause.java @@ -0,0 +1,135 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2024 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.select; + +import java.util.List; +import net.sf.jsqlparser.schema.Table; + +/** + * Represents a FOR UPDATE / FOR SHARE locking clause in a SELECT statement. + * + *

+ * Supports all common SQL dialects: + *

+ *

+ */ +public class ForUpdateClause { + + private ForMode mode; + private List tables; + private Wait wait; + private boolean noWait; + private boolean skipLocked; + + public ForMode getMode() { + return mode; + } + + public ForUpdateClause setMode(ForMode mode) { + this.mode = mode; + return this; + } + + public List
getTables() { + return tables; + } + + public ForUpdateClause setTables(List
tables) { + this.tables = tables; + return this; + } + + /** + * Returns the first table from the OF clause, or {@code null} if none was specified. + * + * @return the first table, or {@code null} + */ + public Table getFirstTable() { + return (tables != null && !tables.isEmpty()) ? tables.get(0) : null; + } + + public Wait getWait() { + return wait; + } + + public ForUpdateClause setWait(Wait wait) { + this.wait = wait; + return this; + } + + public boolean isNoWait() { + return noWait; + } + + public ForUpdateClause setNoWait(boolean noWait) { + this.noWait = noWait; + return this; + } + + public boolean isSkipLocked() { + return skipLocked; + } + + public ForUpdateClause setSkipLocked(boolean skipLocked) { + this.skipLocked = skipLocked; + return this; + } + + /** Returns {@code true} when the mode is {@link ForMode#UPDATE}. */ + public boolean isForUpdate() { + return mode == ForMode.UPDATE; + } + + /** Returns {@code true} when the mode is {@link ForMode#SHARE}. */ + public boolean isForShare() { + return mode == ForMode.SHARE; + } + + /** Returns {@code true} when at least one table was listed in the OF clause. */ + public boolean hasTableList() { + return tables != null && !tables.isEmpty(); + } + + public StringBuilder appendTo(StringBuilder builder) { + builder.append(" FOR ").append(mode.getValue()); + if (tables != null && !tables.isEmpty()) { + builder.append(" OF "); + for (int i = 0; i < tables.size(); i++) { + if (i > 0) { + builder.append(", "); + } + builder.append(tables.get(i)); + } + } + if (wait != null) { + builder.append(wait); + } + if (noWait) { + builder.append(" NOWAIT"); + } else if (skipLocked) { + builder.append(" SKIP LOCKED"); + } + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/select/Select.java b/src/main/java/net/sf/jsqlparser/statement/select/Select.java index 08fed9dee..737608020 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/Select.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/Select.java @@ -24,7 +24,7 @@ import net.sf.jsqlparser.statement.StatementVisitor; public abstract class Select extends ASTNodeAccessImpl implements Statement, Expression, FromItem { - protected Table forUpdateTable = null; + protected List
forUpdateTables = null; protected List> withItemsList; Limit limitBy; Limit limit; @@ -40,6 +40,7 @@ public abstract class Select extends ASTNodeAccessImpl implements Statement, Exp private boolean skipLocked; private Wait wait; private boolean noWait = false; + private boolean forUpdateBeforeOrderBy = false; Alias alias; Pivot pivot; UnPivot unPivot; @@ -291,12 +292,92 @@ public void setForMode(ForMode forMode) { this.forMode = forMode; } + /** + * Returns the first table from the {@code FOR UPDATE OF} clause, or {@code null} if no table + * was specified. Use {@link #getForUpdateTables()} to retrieve all tables. + * + * @return the first table, or {@code null} + */ public Table getForUpdateTable() { - return this.forUpdateTable; + return (forUpdateTables != null && !forUpdateTables.isEmpty()) ? forUpdateTables.get(0) + : null; } + /** + * Sets a single table for the {@code FOR UPDATE OF} clause. + * + * @param forUpdateTable the table, or {@code null} to clear + */ public void setForUpdateTable(Table forUpdateTable) { - this.forUpdateTable = forUpdateTable; + if (forUpdateTable == null) { + this.forUpdateTables = null; + } else { + this.forUpdateTables = new ArrayList<>(); + this.forUpdateTables.add(forUpdateTable); + } + } + + /** + * Returns the list of tables named in the {@code FOR UPDATE OF t1, t2, ...} clause, or + * {@code null} if no OF clause was present. + * + * @return list of tables, or {@code null} + */ + public List
getForUpdateTables() { + return forUpdateTables; + } + + /** + * Sets the list of tables for the {@code FOR UPDATE OF t1, t2, ...} clause. + * + * @param forUpdateTables list of tables + */ + public void setForUpdateTables(List
forUpdateTables) { + this.forUpdateTables = forUpdateTables; + } + + public Select withForUpdateTables(List
forUpdateTables) { + this.setForUpdateTables(forUpdateTables); + return this; + } + + /** + * Builds and returns a {@link ForUpdateClause} representing the current FOR UPDATE / FOR SHARE + * state of this SELECT, or {@code null} if no FOR clause is present. + * + * @return a {@link ForUpdateClause} view, or {@code null} + */ + public ForUpdateClause getForUpdate() { + if (forMode == null) { + return null; + } + ForUpdateClause clause = new ForUpdateClause(); + clause.setMode(forMode); + clause.setTables(forUpdateTables); + clause.setWait(wait); + clause.setNoWait(noWait); + clause.setSkipLocked(skipLocked); + return clause; + } + + /** + * Returns {@code true} when the {@code FOR UPDATE} clause appears before the {@code ORDER BY} + * clause in the original SQL (non-standard ordering supported by some databases). + * + * @return {@code true} if FOR UPDATE precedes ORDER BY + */ + public boolean isForUpdateBeforeOrderBy() { + return forUpdateBeforeOrderBy; + } + + /** + * Indicates whether the {@code FOR UPDATE} clause precedes the {@code ORDER BY} clause in the + * SQL output. + * + * @param forUpdateBeforeOrderBy {@code true} to emit FOR UPDATE before ORDER BY + */ + public void setForUpdateBeforeOrderBy(boolean forUpdateBeforeOrderBy) { + this.forUpdateBeforeOrderBy = forUpdateBeforeOrderBy; } /** @@ -380,7 +461,9 @@ public StringBuilder appendTo(StringBuilder builder) { appendTo(builder, alias, null, pivot, unPivot); - builder.append(orderByToString(oracleSiblings, orderByElements)); + if (!forUpdateBeforeOrderBy) { + builder.append(orderByToString(oracleSiblings, orderByElements)); + } if (forClause != null) { forClause.appendTo(builder); @@ -405,8 +488,14 @@ public StringBuilder appendTo(StringBuilder builder) { builder.append(" FOR "); builder.append(forMode.getValue()); - if (getForUpdateTable() != null) { - builder.append(" OF ").append(forUpdateTable); + if (forUpdateTables != null && !forUpdateTables.isEmpty()) { + builder.append(" OF "); + for (int i = 0; i < forUpdateTables.size(); i++) { + if (i > 0) { + builder.append(", "); + } + builder.append(forUpdateTables.get(i)); + } } if (wait != null) { @@ -421,6 +510,10 @@ public StringBuilder appendTo(StringBuilder builder) { } } + if (forUpdateBeforeOrderBy) { + builder.append(orderByToString(oracleSiblings, orderByElements)); + } + return builder; } 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 73366ce28..69f9412ac 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java @@ -335,7 +335,9 @@ public StringBuilder visit(PlainSelect plainSelect, S context) { unpivot.accept(this, context); } - deparseOrderByElementsClause(plainSelect, plainSelect.getOrderByElements()); + if (!plainSelect.isForUpdateBeforeOrderBy()) { + deparseOrderByElementsClause(plainSelect, plainSelect.getOrderByElements()); + } if (plainSelect.getForClause() != null) { plainSelect.getForClause().appendTo(builder); @@ -363,8 +365,15 @@ public StringBuilder visit(PlainSelect plainSelect, S context) { builder.append(" FOR "); builder.append(plainSelect.getForMode().getValue()); - if (plainSelect.getForUpdateTable() != null) { - builder.append(" OF ").append(plainSelect.getForUpdateTable()); + List
forUpdateTables = plainSelect.getForUpdateTables(); + if (forUpdateTables != null && !forUpdateTables.isEmpty()) { + builder.append(" OF "); + for (int i = 0; i < forUpdateTables.size(); i++) { + if (i > 0) { + builder.append(", "); + } + builder.append(forUpdateTables.get(i)); + } } if (plainSelect.getWait() != null) { // wait's toString will do the formatting for us @@ -376,6 +385,10 @@ public StringBuilder visit(PlainSelect plainSelect, S context) { builder.append(" SKIP LOCKED"); } } + + if (plainSelect.isForUpdateBeforeOrderBy()) { + deparseOrderByElementsClause(plainSelect, plainSelect.getOrderByElements()); + } if (plainSelect.getMySqlSelectIntoClause() != null && plainSelect.getMySqlSelectIntoClause() .getPosition() == MySqlSelectIntoClause.Position.TRAILING) { diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java index d680e9faa..5cab04030 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java @@ -101,6 +101,11 @@ public Void visit(PlainSelect plainSelect, S context) { validateOptionalFeature(c, plainSelect.getForUpdateTable(), Feature.selectForUpdateOfTable); + if (plainSelect.getForUpdateTables() != null) { + plainSelect.getForUpdateTables() + .forEach(t -> validateOptionalFeature(c, t, + Feature.selectForUpdateOfTable)); + } validateOptionalFeature(c, plainSelect.getWait(), Feature.selectForUpdateWait); validateFeature(c, plainSelect.isNoWait(), Feature.selectForUpdateNoWait); validateFeature(c, plainSelect.isSkipLocked(), Feature.selectForUpdateSkipLocked); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index d290ced5e..9d3946191 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -4958,6 +4958,7 @@ PlainSelect PlainSelect() #PlainSelect: List
intoTables = null; MySqlSelectIntoClause mySqlSelectIntoClause = null; Table updateTable = null; + List
updateTables = new ArrayList
(); Wait wait = null; boolean mySqlSqlCalcFoundRows = false; Token token; @@ -5084,10 +5085,17 @@ PlainSelect PlainSelect() #PlainSelect: | ( { plainSelect.setForMode(ForMode.READ_ONLY); }) | ( { plainSelect.setForMode(ForMode.FETCH_ONLY); }) ) - [ LOOKAHEAD(2) updateTable = Table() { plainSelect.setForUpdateTable(updateTable); } ] + [ LOOKAHEAD(2) + updateTable = Table() { updateTables.add(updateTable); } + ( LOOKAHEAD(2) "," updateTable = Table() { updateTables.add(updateTable); } )* + { plainSelect.setForUpdateTables(updateTables); } + ] [ LOOKAHEAD() wait = Wait() { plainSelect.setWait(wait); } ] [ LOOKAHEAD(2) ( { plainSelect.setNoWait(true); } | { plainSelect.setSkipLocked(true); }) ] + [ LOOKAHEAD( ) orderByElements = OrderByElements() + { plainSelect.setOrderByElements(orderByElements); plainSelect.setForUpdateBeforeOrderBy(true); } + ] ] [ LOOKAHEAD( ( | )) mySqlSelectIntoClause = MySqlSelectIntoClause(MySqlSelectIntoClause.Position.TRAILING) diff --git a/src/test/java/net/sf/jsqlparser/statement/select/ForUpdateTest.java b/src/test/java/net/sf/jsqlparser/statement/select/ForUpdateTest.java index ecd216217..23eb3d229 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/ForUpdateTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/ForUpdateTest.java @@ -10,9 +10,13 @@ package net.sf.jsqlparser.statement.select; import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.test.TestUtils; import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + public class ForUpdateTest { @Test @@ -44,4 +48,101 @@ void testMySqlIssue1995() throws JSQLParserException { TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); } + + @Test + void testForUpdateMultipleTables() throws JSQLParserException { + String sqlStr = + "select employee_id from (select employee_id+1 as employee_id from employees)" + + " for update of a, b.c, d skip locked"; + + Statement stmt = TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + PlainSelect plainSelect = (PlainSelect) stmt; + + assertThat(plainSelect.getForMode()).isEqualTo(ForMode.UPDATE); + assertThat(plainSelect.getForUpdateTables()).hasSize(3); + assertThat(plainSelect.isSkipLocked()).isTrue(); + + ForUpdateClause forUpdate = plainSelect.getForUpdate(); + assertThat(forUpdate).isNotNull(); + assertThat(forUpdate.isForUpdate()).isTrue(); + assertThat(forUpdate.getTables()).hasSize(3); + assertThat(forUpdate.isSkipLocked()).isTrue(); + } + + @Test + void testForUpdateOrderByAfter() throws JSQLParserException { + String sqlStr = + "select su.ttype, su.cid, su.s_id, sessiontimezone from sku su" + + " where (nvl(su.up, 'n') = 'n' and su.ttype = :b0)" + + " for update of su.up order by su.d"; + + Statement stmt = TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + PlainSelect plainSelect = (PlainSelect) stmt; + + assertThat(plainSelect.getForMode()).isEqualTo(ForMode.UPDATE); + assertThat(plainSelect.getForUpdateTables()).hasSize(1); + assertThat(plainSelect.getOrderByElements()).hasSize(1); + assertThat(plainSelect.isForUpdateBeforeOrderBy()).isTrue(); + } + + @Test + void testForUpdateDetection() throws JSQLParserException { + Statement stmt = CCJSqlParserUtil.parse("SELECT * FROM users FOR UPDATE"); + PlainSelect plainSelect = (PlainSelect) stmt; + + // ForMode is set for FOR UPDATE + assertThat(plainSelect.getForMode()).isEqualTo(ForMode.UPDATE); + + // getForUpdate() returns a ForUpdateClause + ForUpdateClause forUpdate = plainSelect.getForUpdate(); + assertThat(forUpdate).isNotNull(); + assertThat(forUpdate.isForUpdate()).isTrue(); + assertThat(forUpdate.isForShare()).isFalse(); + assertThat(forUpdate.getTables()).isNull(); + } + + @Test + void testForShare() throws JSQLParserException { + Statement stmt = CCJSqlParserUtil.parse("SELECT * FROM users FOR SHARE"); + PlainSelect plainSelect = (PlainSelect) stmt; + + assertThat(plainSelect.getForMode()).isEqualTo(ForMode.SHARE); + + ForUpdateClause forUpdate = plainSelect.getForUpdate(); + assertThat(forUpdate).isNotNull(); + assertThat(forUpdate.isForShare()).isTrue(); + assertThat(forUpdate.isForUpdate()).isFalse(); + } + + @Test + void testForUpdateNowait() throws JSQLParserException { + String sqlStr = + "select employee_id from (select employee_id+1 as employee_id from employees)" + + " for update of employee_id nowait"; + Statement stmt = TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + PlainSelect plainSelect = (PlainSelect) stmt; + + assertThat(plainSelect.getForMode()).isEqualTo(ForMode.UPDATE); + assertThat(plainSelect.isNoWait()).isTrue(); + + ForUpdateClause forUpdate = plainSelect.getForUpdate(); + assertThat(forUpdate.isNoWait()).isTrue(); + assertThat(forUpdate.isSkipLocked()).isFalse(); + } + + @Test + void testForUpdateWait() throws JSQLParserException { + String sqlStr = + "select employee_id from (select employee_id+1 as employee_id from employees)" + + " for update of employee_id wait 10"; + Statement stmt = TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + PlainSelect plainSelect = (PlainSelect) stmt; + + assertThat(plainSelect.getWait()).isNotNull(); + assertThat(plainSelect.getWait().getTimeout()).isEqualTo(10L); + + ForUpdateClause forUpdate = plainSelect.getForUpdate(); + assertThat(forUpdate.getWait()).isNotNull(); + assertThat(forUpdate.getWait().getTimeout()).isEqualTo(10L); + } } diff --git a/src/test/java/net/sf/jsqlparser/statement/select/SpecialOracleTest.java b/src/test/java/net/sf/jsqlparser/statement/select/SpecialOracleTest.java index 2b2f1b382..4c95872f8 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/SpecialOracleTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/SpecialOracleTest.java @@ -89,6 +89,7 @@ public class SpecialOracleTest { "datetime02.sql", "datetime04.sql", "datetime05.sql", "datetime06.sql", "dblink01.sql", "for_update01.sql", "for_update02.sql", "for_update03.sql", "function04.sql", "function05.sql", "for_update04.sql", "for_update05.sql", "for_update06.sql", + "for_update07.sql", "for_update08.sql", "function01.sql", "function02.sql", "function03.sql", "function06.sql", "function07.sql", "groupby01.sql", diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/oracle-tests/for_update07.sql b/src/test/resources/net/sf/jsqlparser/statement/select/oracle-tests/for_update07.sql index da5b94826..ddd448b75 100644 --- a/src/test/resources/net/sf/jsqlparser/statement/select/oracle-tests/for_update07.sql +++ b/src/test/resources/net/sf/jsqlparser/statement/select/oracle-tests/for_update07.sql @@ -12,4 +12,5 @@ select employee_id from (select employee_id+1 as employee_id from employees) --@FAILURE: Encountered unexpected token: "," "," recorded first on Aug 3, 2021, 7:20:08 AM ---@FAILURE: Encountered: / ",", at line 11, column 19, in lexical state DEFAULT. recorded first on 15 May 2025, 16:24:08 \ No newline at end of file +--@FAILURE: Encountered: / ",", at line 11, column 19, in lexical state DEFAULT. recorded first on 15 May 2025, 16:24:08 +--@SUCCESSFULLY_PARSED_AND_DEPARSED first on Apr 11, 2026, 4:05:21 PM \ No newline at end of file diff --git a/src/test/resources/net/sf/jsqlparser/statement/select/oracle-tests/for_update08.sql b/src/test/resources/net/sf/jsqlparser/statement/select/oracle-tests/for_update08.sql index 5a482f11b..9408f4acf 100644 --- a/src/test/resources/net/sf/jsqlparser/statement/select/oracle-tests/for_update08.sql +++ b/src/test/resources/net/sf/jsqlparser/statement/select/oracle-tests/for_update08.sql @@ -14,4 +14,5 @@ for update of su.up order by su.d --@FAILURE: select su.ttype,su.cid,su.s_id,sessiontimezone from sku su where(nvl(su.up,'n')='n' and su.ttype=:b0)order by su.d for update of su.up recorded first on 20 Apr 2024, 15:59:32 ---@FAILURE: Encountered unexpected token: "order" "ORDER" recorded first on Feb 13, 2025, 10:16:06 AM \ No newline at end of file +--@FAILURE: Encountered unexpected token: "order" "ORDER" recorded first on Feb 13, 2025, 10:16:06 AM +--@SUCCESSFULLY_PARSED_AND_DEPARSED first on Apr 11, 2026, 4:05:22 PM \ No newline at end of file