From c93fa8a9af1af5631255abee5f7dc2ad0be27547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BB=98=E5=85=B8?= Date: Tue, 18 Aug 2026 00:05:45 +0800 Subject: [PATCH] fix: reject illegal MERGE WHEN NOT MATCHED clause pairings (#2480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHEN NOT MATCHED accepted any clause regardless of the BY qualifier, and the AST collapsed the illegal pairings into the opposite semantics on deparse: WHEN NOT MATCHED [BY TARGET] THEN UPDATE/DELETE printed as WHEN MATCHED, WHEN NOT MATCHED BY SOURCE THEN INSERT printed as WHEN NOT MATCHED. SQL Server and BigQuery reject these pairings at parse time. Validate the pairing via MergeSide.validatePairing, mirroring the existing MergeSide.fromImage validation. Signed-off-by: 付典 --- .../jsqlparser/statement/merge/MergeSide.java | 23 +++++++++++++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 2 +- .../jsqlparser/statement/merge/MergeTest.java | 33 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/sf/jsqlparser/statement/merge/MergeSide.java b/src/main/java/net/sf/jsqlparser/statement/merge/MergeSide.java index d871021f7..6dc7815fb 100644 --- a/src/main/java/net/sf/jsqlparser/statement/merge/MergeSide.java +++ b/src/main/java/net/sf/jsqlparser/statement/merge/MergeSide.java @@ -29,6 +29,29 @@ public enum MergeSide { * @throws IllegalArgumentException if {@code image} is neither {@code TARGET} nor * {@code SOURCE} */ + /** + * Validates the clause pairing of a {@code WHEN NOT MATCHED} branch: {@code BY TARGET} (the + * default) only allows an {@code INSERT} clause, {@code BY SOURCE} only allows {@code UPDATE} + * or {@code DELETE}. + * + * @param side the parsed {@code BY TARGET}/{@code BY SOURCE} qualifier, null when absent + * @param operation the parsed clause + * @return the passed {@code operation} + * @throws IllegalArgumentException when the pairing is not legal in any dialect + */ + public static MergeOperation validatePairing(MergeSide side, MergeOperation operation) { + if (side == MergeSide.SOURCE) { + if (operation instanceof MergeInsert) { + throw new IllegalArgumentException( + "WHEN NOT MATCHED BY SOURCE cannot take an INSERT clause"); + } + } else if (!(operation instanceof MergeInsert)) { + throw new IllegalArgumentException( + "WHEN NOT MATCHED [BY TARGET] cannot take an UPDATE or DELETE clause"); + } + return operation; + } + public static MergeSide fromImage(String image) { for (MergeSide value : values()) { if (value.name().equalsIgnoreCase(image)) { diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index d86e227d7..9d0314204 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -4489,7 +4489,7 @@ MergeOperation MergeWhenNotMatched() : { | operation = MergeDeleteClause(predicate) { ((MergeDelete) operation).setSide(side); } ) - { return operation; } + { return MergeSide.validatePairing(side, operation); } } // table names seem to allow ":" delimiters, e.g. for Informix see #1134 diff --git a/src/test/java/net/sf/jsqlparser/statement/merge/MergeTest.java b/src/test/java/net/sf/jsqlparser/statement/merge/MergeTest.java index 27c8c4da0..87d1d1448 100644 --- a/src/test/java/net/sf/jsqlparser/statement/merge/MergeTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/merge/MergeTest.java @@ -25,6 +25,7 @@ import static net.sf.jsqlparser.test.TestUtils.assertOracleHintExists; import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; /** @@ -363,4 +364,36 @@ private static Stream deriveStandardClausesFromOperationsCases() { Arguments.of(Arrays.asList(insert1, insert2, update1, update2, delete1), update1, insert1, true)); } + + @Test + void testMergeRejectsInvalidWhenNotMatchedClausePairings() { + // WHEN MATCHED rows exist in the target: INSERT is not legal + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse( + "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN INSERT (a) VALUES (1)")); + + // WHEN NOT MATCHED [BY TARGET] rows do not exist in the target: only INSERT is legal + assertInvalidMergePairing( + "MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED THEN UPDATE SET a = 1"); + assertInvalidMergePairing( + "MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED THEN DELETE"); + assertInvalidMergePairing( + "MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED BY TARGET THEN UPDATE SET a = 1"); + assertInvalidMergePairing( + "MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED BY TARGET THEN DELETE"); + + // WHEN NOT MATCHED BY SOURCE rows exist in the target: INSERT is not legal + assertInvalidMergePairing( + "MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED BY SOURCE THEN INSERT (a) VALUES (1)"); + } + + private static void assertInvalidMergePairing(String sql) { + JSQLParserException exception = assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql)); + Throwable cause = exception; + while (cause.getCause() != null) { + cause = cause.getCause(); + } + assertThat(cause).isInstanceOf(IllegalArgumentException.class); + assertThat(cause.getMessage()).contains("WHEN NOT MATCHED"); + } }