From f6b1a726e44cad9aea9602195c8ea8492921cb38 Mon Sep 17 00:00:00 2001 From: Dylan Merigaud Date: Sat, 14 Nov 2026 01:31:09 +0100 Subject: [PATCH] fix: parse PostgreSQL SELECT with more than one locking clause PostgreSQL accepts several locking clauses in one SELECT, such as FOR UPDATE OF t FOR SHARE OF u. After the first clause, PlainSelect now reads further FOR UPDATE, FOR SHARE, FOR NO KEY UPDATE and FOR KEY SHARE clauses, each with its optional OF list, WAIT, NOWAIT or SKIP LOCKED, into Select.getAdditionalForUpdateClauses(). The first clause keeps its existing getters. toString, the deparser and the validator handle the additional clauses. Fixes #2926 Signed-off-by: Dylan Merigaud --- .../jsqlparser/statement/select/Select.java | 30 ++++++++++++ .../util/deparser/SelectDeParser.java | 7 +++ .../validation/validator/SelectValidator.java | 24 +++++++++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 34 +++++++++++++ .../statement/select/ForUpdateTest.java | 49 +++++++++++++++++++ .../validator/SelectValidatorTest.java | 10 ++++ 6 files changed, 154 insertions(+) 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 2dd82f93ea..cc880b5853 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/Select.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/Select.java @@ -44,6 +44,7 @@ public abstract class Select extends ASTNodeAccessImpl implements Statement, Exp private Wait wait; private boolean noWait = false; private boolean forUpdateBeforeOrderBy = false; + private List additionalForUpdateClauses = null; Alias alias; Pivot pivot; UnPivot unPivot; @@ -416,6 +417,30 @@ public ForUpdateClause getForUpdate() { return clause; } + /** + * Returns the locking clauses that follow the first one, as in PostgreSQL's + * {@code FOR UPDATE OF t FOR SHARE OF u}, or {@code null} if there are none. The first clause + * is the one {@link #getForMode()}, {@link #getForUpdateTables()}, {@link #getWait()}, + * {@link #isNoWait()} and {@link #isSkipLocked()} describe. + * + * @return the locking clauses after the first one, or {@code null} + */ + public List getAdditionalForUpdateClauses() { + return additionalForUpdateClauses; + } + + public void setAdditionalForUpdateClauses(List additionalForUpdateClauses) { + this.additionalForUpdateClauses = additionalForUpdateClauses; + } + + public Select addAdditionalForUpdateClause(ForUpdateClause forUpdateClause) { + if (additionalForUpdateClauses == null) { + additionalForUpdateClauses = new ArrayList<>(); + } + additionalForUpdateClauses.add(forUpdateClause); + return this; + } + /** * 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). @@ -572,6 +597,11 @@ public StringBuilder appendTo(StringBuilder builder) { } else if (isSkipLocked()) { builder.append(" SKIP LOCKED"); } + if (additionalForUpdateClauses != null) { + for (ForUpdateClause forUpdateClause : additionalForUpdateClauses) { + forUpdateClause.appendTo(builder); + } + } } if (forUpdateBeforeOrderBy) { 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 2e293b7d20..47ea406c22 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java @@ -50,6 +50,7 @@ import net.sf.jsqlparser.statement.select.Distinct; import net.sf.jsqlparser.statement.select.Fetch; import net.sf.jsqlparser.statement.select.First; +import net.sf.jsqlparser.statement.select.ForUpdateClause; import net.sf.jsqlparser.statement.select.FromItem; import net.sf.jsqlparser.statement.select.FromItemVisitor; import net.sf.jsqlparser.statement.select.InterpolateElement; @@ -313,6 +314,12 @@ public StringBuilder visit(PlainSelect plainSelect, S context) { } else if (plainSelect.isSkipLocked()) { builder.append(" SKIP LOCKED"); } + if (plainSelect.getAdditionalForUpdateClauses() != null) { + for (ForUpdateClause forUpdateClause : plainSelect + .getAdditionalForUpdateClauses()) { + forUpdateClause.appendTo(builder); + } + } } if (plainSelect.isForUpdateBeforeOrderBy()) { 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 43cc70d749..9681622879 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 @@ -23,6 +23,7 @@ import net.sf.jsqlparser.statement.select.ExceptOp; import net.sf.jsqlparser.statement.select.Fetch; import net.sf.jsqlparser.statement.select.ForMode; +import net.sf.jsqlparser.statement.select.ForUpdateClause; import net.sf.jsqlparser.statement.select.FromItemVisitor; import net.sf.jsqlparser.statement.select.InterpolateElement; import net.sf.jsqlparser.statement.select.IntersectOp; @@ -142,6 +143,29 @@ public Void visit(PlainSelect plainSelect, S context) { validateOptionalFeature(c, plainSelect.getWait(), Feature.selectForUpdateWait); validateFeature(c, plainSelect.isNoWait(), Feature.selectForUpdateNoWait); validateFeature(c, plainSelect.isSkipLocked(), Feature.selectForUpdateSkipLocked); + + if (plainSelect.getAdditionalForUpdateClauses() != null) { + for (ForUpdateClause forUpdateClause : plainSelect + .getAdditionalForUpdateClauses()) { + validateFeature(c, forUpdateClause.getMode() == ForMode.KEY_SHARE, + Feature.selectForKeyShare); + validateFeature(c, forUpdateClause.getMode() == ForMode.NO_KEY_UPDATE, + Feature.selectForNoKeyUpdate); + validateFeature(c, forUpdateClause.getMode() == ForMode.SHARE, + Feature.selectForShare); + if (forUpdateClause.getTables() != null) { + forUpdateClause.getTables() + .forEach(t -> validateOptionalFeature(c, t, + Feature.selectForUpdateOfTable)); + } + validateOptionalFeature(c, forUpdateClause.getWait(), + Feature.selectForUpdateWait); + validateFeature(c, forUpdateClause.isNoWait(), + Feature.selectForUpdateNoWait); + validateFeature(c, forUpdateClause.isSkipLocked(), + Feature.selectForUpdateSkipLocked); + } + } } validateOptionalFeature(c, plainSelect.getForXmlPath(), Feature.selectForXmlPath); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index f21f6617b8..5f79972818 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -12517,6 +12517,34 @@ void MySqlSelectModifier(PlainSelect select): } } +ForUpdateClause AdditionalForUpdateClause(): +{ + ForUpdateClause clause = new ForUpdateClause(); + List tables = new ArrayList
(); + Table table; + Wait wait; +} +{ + + ( + { clause.setMode(ForMode.UPDATE); } + | { clause.setMode(ForMode.SHARE); } + | ( { clause.setMode(ForMode.NO_KEY_UPDATE); }) + | ( { clause.setMode(ForMode.KEY_SHARE); }) + ) + [ LOOKAHEAD(2) + table = Table() { tables.add(table); } + ( LOOKAHEAD(2) "," table = Table() { tables.add(table); } )* + { clause.setTables(tables); } + ] + [ LOOKAHEAD(1, ) wait = Wait() { clause.setWait(wait); } ] + [ LOOKAHEAD(2) ( { clause.setNoWait(true); } + | { clause.setSkipLocked(true); }) ] + { + return clause; + } +} + ForClause ForClause() #ForClause: { Token token = null; @@ -12653,6 +12681,7 @@ PlainSelect PlainSelect() #PlainSelect: MySqlProcedureAnalyse mySqlProcedureAnalyse = null; Table updateTable = null; List
updateTables = new ArrayList
(); + ForUpdateClause additionalForUpdateClause; Wait wait = null; Token token; KSQLWindow ksqlWindow = null; @@ -12819,6 +12848,11 @@ PlainSelect PlainSelect() #PlainSelect: [ LOOKAHEAD(1, ) wait = Wait() { plainSelect.setWait(wait); } ] [ LOOKAHEAD(2) ( { plainSelect.setNoWait(true); } | { plainSelect.setSkipLocked(true); }) ] + // PostgreSQL: more locking clauses, e.g. FOR UPDATE OF t FOR SHARE OF u + ( LOOKAHEAD( ( | | | )) + additionalForUpdateClause = AdditionalForUpdateClause() + { plainSelect.addAdditionalForUpdateClause(additionalForUpdateClause); } + )* [ LOOKAHEAD( ) orderByElements = OrderByElements() { plainSelect.setOrderByElements(orderByElements); plainSelect.setForUpdateBeforeOrderBy(true); } ] 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 23eb3d2297..f082ea4581 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/ForUpdateTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/ForUpdateTest.java @@ -11,10 +11,13 @@ import net.sf.jsqlparser.JSQLParserException; import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Table; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.test.TestUtils; import org.junit.jupiter.api.Test; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; public class ForUpdateTest { @@ -145,4 +148,50 @@ void testForUpdateWait() throws JSQLParserException { assertThat(forUpdate.getWait()).isNotNull(); assertThat(forUpdate.getWait().getTimeout()).isEqualTo(10L); } + + @Test + void testPostgresMultipleLockingClausesIssue2926() throws JSQLParserException { + String sqlStr = "SELECT * FROM t JOIN u ON u.t_id = t.id FOR UPDATE OF t FOR SHARE OF u"; + Statement stmt = TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + PlainSelect plainSelect = (PlainSelect) stmt; + + assertThat(plainSelect.getForMode()).isEqualTo(ForMode.UPDATE); + assertThat(plainSelect.getForUpdateTables()).extracting(Table::getName) + .containsExactly("t"); + + assertThat(plainSelect.getAdditionalForUpdateClauses()).hasSize(1); + ForUpdateClause second = plainSelect.getAdditionalForUpdateClauses().get(0); + assertThat(second.isForShare()).isTrue(); + assertThat(second.getTables()).extracting(Table::getName).containsExactly("u"); + } + + @Test + void testPostgresMultipleLockingClausesWithOptions() throws JSQLParserException { + String sqlStr = "SELECT * FROM a, b, c, d" + + " FOR NO KEY UPDATE OF a NOWAIT" + + " FOR KEY SHARE OF b, c SKIP LOCKED" + + " FOR SHARE" + + " FOR UPDATE OF d NOWAIT"; + Statement stmt = TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + PlainSelect plainSelect = (PlainSelect) stmt; + + assertThat(plainSelect.getForMode()).isEqualTo(ForMode.NO_KEY_UPDATE); + assertThat(plainSelect.isNoWait()).isTrue(); + + List more = plainSelect.getAdditionalForUpdateClauses(); + assertThat(more).extracting(ForUpdateClause::getMode) + .containsExactly(ForMode.KEY_SHARE, ForMode.SHARE, ForMode.UPDATE); + assertThat(more.get(0).getTables()).extracting(Table::getName) + .containsExactly("b", "c"); + assertThat(more.get(0).isSkipLocked()).isTrue(); + assertThat(more.get(1).getTables()).isNull(); + assertThat(more.get(2).isNoWait()).isTrue(); + } + + @Test + void testSingleLockingClauseHasNoAdditionalClauses() throws JSQLParserException { + PlainSelect plainSelect = + (PlainSelect) CCJSqlParserUtil.parse("SELECT * FROM t FOR UPDATE OF t"); + assertThat(plainSelect.getAdditionalForUpdateClauses()).isNull(); + } } diff --git a/src/test/java/net/sf/jsqlparser/util/validation/validator/SelectValidatorTest.java b/src/test/java/net/sf/jsqlparser/util/validation/validator/SelectValidatorTest.java index c6ffec2fd8..5a78561668 100644 --- a/src/test/java/net/sf/jsqlparser/util/validation/validator/SelectValidatorTest.java +++ b/src/test/java/net/sf/jsqlparser/util/validation/validator/SelectValidatorTest.java @@ -121,6 +121,16 @@ public void testValidationForPostgresShare() throws JSQLParserException { Feature.selectForNoKeyUpdate); } + @Test + public void testValidationMultipleLockingClauses() throws JSQLParserException { + String sql = "SELECT * FROM t, u FOR UPDATE OF t FOR SHARE OF u"; + validateNoErrors(sql, 1, DatabaseType.POSTGRESQL); + + String sql2 = "SELECT * FROM t FOR UPDATE FOR NO KEY UPDATE"; + validateNotSupported(sql2, 1, 1, DatabaseType.ORACLE, + Feature.selectForNoKeyUpdate); + } + @Test public void testValidationForUpdateNoWait() throws JSQLParserException { String sql = "SELECT * FROM mytable FOR UPDATE NOWAIT";