From bcc6de6afa14adb74688ec13963d43e688a7357f Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Sat, 12 Sep 2026 07:10:38 +0000 Subject: [PATCH] fix: recognise set operation quantifiers written in any case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grammar records a set operation's modifier verbatim from the source, so `union all` arrives as "all" while `isAll()` asked whether the modifier contained "ALL". Every spelling but upper case therefore read as an unqualified UNION, silently dropping ALL semantics — and the same for DISTINCT, across UNION, EXCEPT, INTERSECT and MINUS alike. A regression from 5fe938bc, which replaced the boolean `all` and `distinct` fields with a single verbatim modifier string. The round-trip tests all spell their SQL in upper case, so none of them noticed. Match the leading keyword case-insensitively instead. The match is anchored at the start of the modifier and must end on a word boundary, so a `CORRESPONDING BY NAME MATCHING(all)` column list is no longer mistaken for the ALL quantifier the way a bare `contains` would. `setAll` and `setDistinct` wrote their keyword whatever they were passed, so `setAll(false)` left an ALL modifier behind. They now honour the argument. --- .../statement/select/SetOperation.java | 24 +++++-- .../select/SetOperationModifierTest.java | 67 +++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/sf/jsqlparser/statement/select/SetOperation.java b/src/main/java/net/sf/jsqlparser/statement/select/SetOperation.java index 4438d75376..b08471bd82 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/SetOperation.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/SetOperation.java @@ -20,19 +20,35 @@ public String getModifier() { } public boolean isAll() { - return modifier != null && modifier.contains("ALL"); + return leadsWith("ALL"); } public void setAll(boolean all) { - this.modifier = "ALL"; + this.modifier = all ? "ALL" : ""; } public boolean isDistinct() { - return modifier != null && modifier.contains("DISTINCT"); + return leadsWith("DISTINCT"); } public void setDistinct(boolean distinct) { - this.modifier = "DISTINCT"; + this.modifier = distinct ? "DISTINCT" : ""; + } + + /** + * Whether the modifier opens with {@code keyword}, which the grammar records verbatim from the + * source and so may be written in any case. Anchored at the start and required to end on a word + * boundary, so a {@code CORRESPONDING BY NAME MATCHING(all)} column list cannot be mistaken for + * the ALL quantifier. + */ + private boolean leadsWith(String keyword) { + if (modifier == null) { + return false; + } + String trimmed = modifier.trim(); + return trimmed.regionMatches(true, 0, keyword, 0, keyword.length()) + && (trimmed.length() == keyword.length() + || !Character.isLetterOrDigit(trimmed.charAt(keyword.length()))); } private final SetOperationType type; diff --git a/src/test/java/net/sf/jsqlparser/statement/select/SetOperationModifierTest.java b/src/test/java/net/sf/jsqlparser/statement/select/SetOperationModifierTest.java index a30f3e2fa0..5af4513f0a 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/SetOperationModifierTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/SetOperationModifierTest.java @@ -16,6 +16,7 @@ import net.sf.jsqlparser.JSQLParserException; import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.statement.Statement; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; import org.junit.jupiter.params.ParameterizedTest; @@ -50,6 +51,72 @@ void testSetOperationModifierRoundTrip(String sql) throws JSQLParserException { assertSqlCanBeParsedAndDeparsed(sql); } + /** + * The grammar records the modifier verbatim from the source, so the quantifier survives in + * whatever case it was written. Every case spells the same set operation. + * + * @see #2419 + */ + @ParameterizedTest + @ValueSource(strings = {"ALL", "all", "All", "aLL"}) + void testAllModifierRecognisedInAnyCase(String quantifier) throws JSQLParserException { + SetOperation operation = firstOperation( + "SELECT a FROM t1 UNION " + quantifier + " SELECT a FROM t2"); + assertTrue(operation.isAll(), "UNION " + quantifier + " should be an ALL union"); + assertFalse(operation.isDistinct(), "UNION " + quantifier + " is not DISTINCT"); + } + + @ParameterizedTest + @ValueSource(strings = {"DISTINCT", "distinct", "Distinct"}) + void testDistinctModifierRecognisedInAnyCase(String quantifier) throws JSQLParserException { + SetOperation operation = firstOperation( + "SELECT a FROM t1 UNION " + quantifier + " SELECT a FROM t2"); + assertTrue(operation.isDistinct(), "UNION " + quantifier + " should be a DISTINCT union"); + assertFalse(operation.isAll(), "UNION " + quantifier + " is not ALL"); + } + + @ParameterizedTest + @ValueSource(strings = {"EXCEPT", "INTERSECT", "MINUS"}) + void testAllModifierRecognisedInAnyCaseForEverySetOperation(String setOperation) + throws JSQLParserException { + assertTrue(firstOperation("SELECT a FROM t1 " + setOperation + " all SELECT a FROM t2") + .isAll(), setOperation + " all should be an ALL operation"); + } + + /** + * A plain set operation carries no quantifier, and neither predicate may claim one. + */ + @Test + void testUnqualifiedSetOperationIsNeitherAllNorDistinct() throws JSQLParserException { + SetOperation operation = firstOperation("SELECT a FROM t1 UNION SELECT a FROM t2"); + assertFalse(operation.isAll()); + assertFalse(operation.isDistinct()); + } + + /** + * The setters record what they are told rather than the keyword they are named after. + */ + @Test + void testSettersHonourTheirArgument() { + UnionOp union = new UnionOp(); + + union.setAll(true); + assertTrue(union.isAll()); + union.setAll(false); + assertFalse(union.isAll(), "setAll(false) must not leave an ALL modifier behind"); + + union.setDistinct(true); + assertTrue(union.isDistinct()); + union.setDistinct(false); + assertFalse(union.isDistinct(), "setDistinct(false) must not leave a DISTINCT modifier"); + } + + private static SetOperation firstOperation(String sql) throws JSQLParserException { + Statement statement = CCJSqlParserUtil.parse(sql); + assertInstanceOf(SetOperationList.class, statement); + return ((SetOperationList) statement).getOperations().get(0); + } + @ParameterizedTest @MethodSource("provideModifierLeakCases") void testModifierDoesNotLeakBetweenOperators(String sql, String forbidden)