diff --git a/src/main/java/net/sf/jsqlparser/statement/merge/MergeDelete.java b/src/main/java/net/sf/jsqlparser/statement/merge/MergeDelete.java index bfacef1c1..16bfc495d 100644 --- a/src/main/java/net/sf/jsqlparser/statement/merge/MergeDelete.java +++ b/src/main/java/net/sf/jsqlparser/statement/merge/MergeDelete.java @@ -15,6 +15,7 @@ public class MergeDelete implements Serializable, MergeOperation { private Expression andPredicate; + private MergeSide side; public Expression getAndPredicate() { return andPredicate; @@ -24,11 +25,25 @@ public void setAndPredicate(Expression andPredicate) { this.andPredicate = andPredicate; } + public MergeSide getSide() { + return side; + } + + public MergeDelete setSide(MergeSide side) { + this.side = side; + return this; + } + public MergeDelete withAndPredicate(Expression andPredicate) { this.setAndPredicate(andPredicate); return this; } + public MergeDelete withSide(MergeSide side) { + this.setSide(side); + return this; + } + @Override public T accept(MergeOperationVisitor mergeOperationVisitor, S context) { return mergeOperationVisitor.visit(this, context); @@ -37,7 +52,7 @@ public T accept(MergeOperationVisitor mergeOperationVisitor, S context @Override public String toString() { StringBuilder b = new StringBuilder(); - b.append(" WHEN MATCHED"); + b.append(side == MergeSide.SOURCE ? " WHEN NOT MATCHED BY SOURCE" : " WHEN MATCHED"); if (andPredicate != null) { b.append(" AND ").append(andPredicate.toString()); } diff --git a/src/main/java/net/sf/jsqlparser/statement/merge/MergeInsert.java b/src/main/java/net/sf/jsqlparser/statement/merge/MergeInsert.java index 7e33db8b0..411e5e2bd 100644 --- a/src/main/java/net/sf/jsqlparser/statement/merge/MergeInsert.java +++ b/src/main/java/net/sf/jsqlparser/statement/merge/MergeInsert.java @@ -24,6 +24,15 @@ public class MergeInsert implements Serializable, MergeOperation { private ExpressionList columns; private ExpressionList values; private Expression whereCondition; + private MergeSide side; + + public MergeSide getSide() { + return side; + } + + public void setSide(MergeSide side) { + this.side = side; + } public Expression getAndPredicate() { return andPredicate; @@ -66,6 +75,9 @@ public T accept(MergeOperationVisitor mergeOperationVisitor, S context public String toString() { StringBuilder b = new StringBuilder(); b.append(" WHEN NOT MATCHED"); + if (side == MergeSide.TARGET) { + b.append(" BY TARGET"); + } if (andPredicate != null) { b.append(" AND ").append(andPredicate); } @@ -122,6 +134,11 @@ public MergeInsert withWhereCondition(Expression whereCondition) { return this; } + public MergeInsert withSide(MergeSide side) { + this.setSide(side); + return this; + } + public E getAndPredicate(Class type) { return type.cast(getAndPredicate()); } diff --git a/src/main/java/net/sf/jsqlparser/statement/merge/MergeSide.java b/src/main/java/net/sf/jsqlparser/statement/merge/MergeSide.java new file mode 100644 index 000000000..d871021f7 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/merge/MergeSide.java @@ -0,0 +1,41 @@ +/*- + * #%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.merge; + +/** + * Identifies the side of a {@code MERGE ... WHEN NOT MATCHED [BY TARGET|BY SOURCE]} clause. + * + *

+ * Standard SQL only knows {@code WHEN [NOT] MATCHED}; SQL Server and BigQuery additionally allow + * the NOT MATCHED clause to be qualified with {@code BY TARGET} (the default, allows + * {@code INSERT}) or {@code BY SOURCE} (allows {@code UPDATE}/{@code DELETE}). + *

+ */ +public enum MergeSide { + TARGET, SOURCE; + + /** + * Parses a {@code BY TARGET}/{@code BY SOURCE} qualifier, case-insensitively. + * + * @param image the raw identifier image following {@code BY} + * @return the matching {@link MergeSide} + * @throws IllegalArgumentException if {@code image} is neither {@code TARGET} nor + * {@code SOURCE} + */ + public static MergeSide fromImage(String image) { + for (MergeSide value : values()) { + if (value.name().equalsIgnoreCase(image)) { + return value; + } + } + throw new IllegalArgumentException( + "Expected TARGET or SOURCE after BY but found: " + image); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/merge/MergeUpdate.java b/src/main/java/net/sf/jsqlparser/statement/merge/MergeUpdate.java index 2cae3e176..e64cab513 100644 --- a/src/main/java/net/sf/jsqlparser/statement/merge/MergeUpdate.java +++ b/src/main/java/net/sf/jsqlparser/statement/merge/MergeUpdate.java @@ -21,6 +21,7 @@ public class MergeUpdate implements Serializable, MergeOperation { private Expression andPredicate; private Expression whereCondition; private Expression deleteWhereCondition; + private MergeSide side; public MergeUpdate() {} @@ -61,6 +62,15 @@ public void setDeleteWhereCondition(Expression deleteWhereCondition) { this.deleteWhereCondition = deleteWhereCondition; } + public MergeSide getSide() { + return side; + } + + public MergeUpdate setSide(MergeSide side) { + this.side = side; + return this; + } + @Override public T accept(MergeOperationVisitor mergeOperationVisitor, S context) { return mergeOperationVisitor.visit(this, context); @@ -69,7 +79,7 @@ public T accept(MergeOperationVisitor mergeOperationVisitor, S context @Override public String toString() { StringBuilder b = new StringBuilder(); - b.append(" WHEN MATCHED"); + b.append(side == MergeSide.SOURCE ? " WHEN NOT MATCHED BY SOURCE" : " WHEN MATCHED"); if (andPredicate != null) { b.append(" AND ").append(andPredicate.toString()); } @@ -100,6 +110,11 @@ public MergeUpdate withDeleteWhereCondition(Expression deleteWhereCondition) { return this; } + public MergeUpdate withSide(MergeSide side) { + this.setSide(side); + return this; + } + public E getAndPredicate(Class type) { return type.cast(getAndPredicate()); } diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/MergeDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/MergeDeParser.java index 5ce81c2dd..003527770 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/MergeDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/MergeDeParser.java @@ -67,7 +67,8 @@ public void deParse(Merge merge) { @Override public StringBuilder visit(MergeDelete mergeDelete, S context) { - builder.append(" WHEN MATCHED"); + builder.append(mergeDelete.getSide() == MergeSide.SOURCE ? " WHEN NOT MATCHED BY SOURCE" + : " WHEN MATCHED"); if (mergeDelete.getAndPredicate() != null) { builder.append(" AND "); mergeDelete.getAndPredicate().accept(expressionDeParser, context); @@ -82,7 +83,8 @@ public void visit(MergeDelete mergeDelete) { @Override public StringBuilder visit(MergeUpdate mergeUpdate, S context) { - builder.append(" WHEN MATCHED"); + builder.append(mergeUpdate.getSide() == MergeSide.SOURCE ? " WHEN NOT MATCHED BY SOURCE" + : " WHEN MATCHED"); if (mergeUpdate.getAndPredicate() != null) { builder.append(" AND "); mergeUpdate.getAndPredicate().accept(expressionDeParser, context); @@ -110,6 +112,9 @@ public void visit(MergeUpdate mergeUpdate) { @Override public StringBuilder visit(MergeInsert mergeInsert, S context) { builder.append(" WHEN NOT MATCHED"); + if (mergeInsert.getSide() == MergeSide.TARGET) { + builder.append(" BY TARGET"); + } if (mergeInsert.getAndPredicate() != null) { builder.append(" AND "); mergeInsert.getAndPredicate().accept(expressionDeParser, context); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index f6dc2e737..00620fde9 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -4225,31 +4225,53 @@ MergeOperation MergeUpdateClause(Expression predicate) : { { return mu; } } -MergeOperation MergeWhenNotMatched() : { +MergeInsert MergeInsertClause(Expression predicate) : { MergeInsert mi = new MergeInsert(); - Expression predicate; ExpressionList columns; ExpressionList expList; Expression condition; } { - - [ predicate = Expression() { mi.setAndPredicate(predicate); } ] - - - [ "(" columns = ColumnList() ")" - { - mi.setColumns( new ParenthesedExpressionList(columns) ); - } - ] - "(" expList = SimpleExpressionList() ")" + + [ "(" columns = ColumnList() ")" { - mi.setValues( new ParenthesedExpressionList(expList) ); + mi.setColumns( new ParenthesedExpressionList(columns) ); } + ] + "(" expList = SimpleExpressionList() ")" + { + mi.setValues( new ParenthesedExpressionList(expList) ); + } + + [ condition = Expression() { mi.setWhereCondition(condition); } ] - [ condition = Expression() { mi.setWhereCondition(condition); }] + { + mi.setAndPredicate(predicate); + return mi; + } +} - { return mi; } +MergeOperation MergeWhenNotMatched() : { + MergeOperation operation; + Expression predicate = null; + MergeSide side = null; + Token sideToken; +} +{ + + [ sideToken = { + side = MergeSide.fromImage(sideToken.image); + } ] + [ predicate = Expression() ] + + ( + operation = MergeInsertClause(predicate) { ((MergeInsert) operation).setSide(side); } + | + operation = MergeUpdateClause(predicate) { ((MergeUpdate) operation).setSide(side); } + | + operation = MergeDeleteClause(predicate) { ((MergeDelete) operation).setSide(side); } + ) + { return 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 470b8c163..27c8c4da0 100644 --- a/src/test/java/net/sf/jsqlparser/statement/merge/MergeTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/merge/MergeTest.java @@ -272,6 +272,43 @@ void testSnowflakeMergeStatementWithManyWhensAndDelete() throws JSQLParserExcept assertSqlCanBeParsedAndDeparsed(sql, true); } + @Test + void testBigQueryMergeNotMatchedByTargetAndSource() throws JSQLParserException { + // BigQuery/SQL Server allow "WHEN NOT MATCHED [BY TARGET|BY SOURCE]" (issue #2421) + String sql = + "MERGE INTO target_table AS tt USING (SELECT key, field FROM source_table) AS st" + + " ON tt.key = st.key" + + " WHEN NOT MATCHED BY TARGET THEN INSERT (key, field) VALUES (st.key, st.field)" + + " WHEN NOT MATCHED BY SOURCE THEN DELETE"; + + assertSqlCanBeParsedAndDeparsed(sql, true); + + Merge merge = (Merge) CCJSqlParserUtil.parse(sql); + assertThat(merge.getOperations()).hasSize(2); + assertThat(merge.getOperations().get(0)).isInstanceOf(MergeInsert.class); + assertThat(((MergeInsert) merge.getOperations().get(0)).getSide()) + .isEqualTo(MergeSide.TARGET); + assertThat(merge.getOperations().get(1)).isInstanceOf(MergeDelete.class); + assertThat(((MergeDelete) merge.getOperations().get(1)).getSide()) + .isEqualTo(MergeSide.SOURCE); + } + + @Test + void testBigQueryMergeNotMatchedBySourceUpdate() throws JSQLParserException { + // BY SOURCE allows UPDATE/DELETE with an AND search condition + String sql = "MERGE INTO t USING s ON t.id = s.id" + + " WHEN NOT MATCHED BY SOURCE AND s.id IS NULL THEN UPDATE SET t.v = 0"; + + assertSqlCanBeParsedAndDeparsed(sql, true); + + Merge merge = (Merge) CCJSqlParserUtil.parse(sql); + assertThat(merge.getOperations()).hasSize(1); + assertThat(merge.getOperations().get(0)).isInstanceOf(MergeUpdate.class); + assertThat(((MergeUpdate) merge.getOperations().get(0)).getSide()) + .isEqualTo(MergeSide.SOURCE); + assertThat(((MergeUpdate) merge.getOperations().get(0)).getAndPredicate()).isNotNull(); + } + @ParameterizedTest @MethodSource("deriveOperationsFromStandardClausesCases") void testDeriveOperationsFromStandardClauses(List expectedOperations,