diff --git a/presto-docs/src/main/sphinx/sql/merge.rst b/presto-docs/src/main/sphinx/sql/merge.rst index 74a0b6473a39b..2174b76120e0e 100644 --- a/presto-docs/src/main/sphinx/sql/merge.rst +++ b/presto-docs/src/main/sphinx/sql/merge.rst @@ -12,6 +12,8 @@ Synopsis ON search_condition WHEN MATCHED THEN UPDATE SET ( column = expression [, ...] ) + WHEN MATCHED THEN + DELETE WHEN NOT MATCHED THEN INSERT [ column_list ] VALUES (expression, ...) @@ -19,11 +21,17 @@ Synopsis Description ----------- -The ``MERGE`` statement inserts or updates rows in a ``target_table`` based on the contents of the ``source_table``. +The ``MERGE`` statement conditionally inserts, updates, or deletes rows in a ``target_table`` based on the contents of the ``source_table``. The ``search_condition`` defines a relation between the source and target tables. -When the condition is met, the target row is updated. When the condition is not met, a new row is inserted into the target table. -In the ``MATCHED`` case, the ``UPDATE`` column value expressions can depend on any field of the target or the source. -In the ``NOT MATCHED`` case, the ``INSERT`` expressions can depend on any field of the source. + +When the condition is met, one of the following ``MATCHED`` actions can be taken: + +* ``UPDATE``: The target row is updated. The ``UPDATE`` column value expressions can depend on any field of the target or the source. +* ``DELETE``: The target row is deleted from the target table. + +When the condition is not met, the ``NOT MATCHED`` action inserts a new row into the target table. The ``INSERT`` expressions can depend on any field of the source. + +A ``MERGE`` statement can contain any combination of ``WHEN MATCHED`` and ``WHEN NOT MATCHED`` clauses. For example, you can use ``WHEN MATCHED THEN DELETE`` together with ``WHEN NOT MATCHED THEN INSERT`` to delete existing matched rows and insert new unmatched rows in a single atomic operation. The ``MERGE`` command requires each target row to match at most one source row. An exception is raised when a single target table row matches more than one source row. If a source row is not matched by the ``WHEN MATCHED`` clause and there is no ``WHEN NOT MATCHED`` clause, the source row is ignored. @@ -39,12 +47,16 @@ MERGE Command Privileges The ``MERGE`` statement does not have a dedicated privilege. Instead, executing a ``MERGE`` statement requires the privileges associated with the individual actions it performs: * ``UPDATE`` actions: require the ``UPDATE`` privilege on the target table columns referenced in the ``SET`` clause. +* ``DELETE`` actions: require the ``DELETE`` privilege on the target table. * ``INSERT`` actions: require the ``INSERT`` privilege on the target table. Each privilege must be granted to the user executing the ``MERGE`` command, based on the specific operations included in the statement. -Example -------- +Examples +-------- + +Update and insert +^^^^^^^^^^^^^^^^^ Update the sales information for existing products and insert the sales information for the new products in the market. @@ -62,6 +74,35 @@ Update the sales information for existing products and insert the sales informat INSERT (product_id, sales, last_sale, current_price) VALUES (ms.product_id, ms.sales, ms.sale_date, ms.price) +Delete and insert +^^^^^^^^^^^^^^^^^ + +Delete matched rows from the target table and insert unmatched rows from the source. This is useful for replacing existing records with new data in a single atomic operation. + +.. code-block:: text + + MERGE INTO product_sales AS s + USING monthly_sales AS ms + ON s.product_id = ms.product_id + WHEN MATCHED THEN + DELETE + WHEN NOT MATCHED THEN + INSERT (product_id, sales, last_sale, current_price) + VALUES (ms.product_id, ms.sales, ms.sale_date, ms.price) + +Delete only +^^^^^^^^^^^ + +Delete all rows in the target table that match the source. Rows in the target table that have no match in the source remain unchanged. + +.. code-block:: text + + MERGE INTO product_sales AS s + USING discontinued_products AS d + ON s.product_id = d.product_id + WHEN MATCHED THEN + DELETE + Limitations ----------- diff --git a/presto-iceberg/src/test/java/com/facebook/presto/iceberg/IcebergDistributedTestBase.java b/presto-iceberg/src/test/java/com/facebook/presto/iceberg/IcebergDistributedTestBase.java index bc63ed871e89e..da7af7149f975 100644 --- a/presto-iceberg/src/test/java/com/facebook/presto/iceberg/IcebergDistributedTestBase.java +++ b/presto-iceberg/src/test/java/com/facebook/presto/iceberg/IcebergDistributedTestBase.java @@ -3315,6 +3315,85 @@ public void testMergeSimpleQuery(String partitioning) } } + @Test + public void testMergeDeleteWithInsert() + { + String targetTable = "merge_delete_insert_" + randomTableSuffix(); + try { + assertUpdate(format("CREATE TABLE %s (customer VARCHAR, purchases INT, address VARCHAR)", targetTable)); + assertUpdate(format("INSERT INTO %s (customer, purchases, address) VALUES ('Aaron', 5, 'Antioch'), ('Bill', 7, 'Buena'), ('Carol', 3, 'Cambridge'), ('Dave', 11, 'Devon')", targetTable), 4); + + @Language("SQL") String sqlMergeCommand = + format("MERGE INTO %s t USING ", targetTable) + + "(VALUES ('Aaron', 6, 'Arches'), ('Carol', 9, 'Centreville'), ('Ed', 7, 'Etherville')) AS s(customer, purchases, address) " + + "ON (t.customer = s.customer) " + + "WHEN MATCHED THEN" + + " DELETE " + + "WHEN NOT MATCHED THEN" + + " INSERT (customer, purchases, address) VALUES(s.customer, s.purchases, s.address)"; + + assertUpdate(sqlMergeCommand, 3); + + assertQuery("SELECT * FROM " + targetTable, + "VALUES ('Bill', 7, 'Buena'), ('Dave', 11, 'Devon'), ('Ed', 7, 'Etherville')"); + } + finally { + assertUpdate("DROP TABLE " + targetTable); + } + } + + @Test + public void testMergeDeleteOnly() + { + String targetTable = "merge_delete_only_" + randomTableSuffix(); + try { + assertUpdate(format("CREATE TABLE %s (customer VARCHAR, purchases INT, address VARCHAR)", targetTable)); + assertUpdate(format("INSERT INTO %s (customer, purchases, address) VALUES ('Aaron', 5, 'Antioch'), ('Bill', 7, 'Buena'), ('Carol', 3, 'Cambridge'), ('Dave', 11, 'Devon')", targetTable), 4); + + @Language("SQL") String sqlMergeCommand = + format("MERGE INTO %s t USING ", targetTable) + + "(VALUES ('Aaron', 6, 'Arches'), ('Carol', 9, 'Centreville')) AS s(customer, purchases, address) " + + "ON (t.customer = s.customer) " + + "WHEN MATCHED THEN" + + " DELETE"; + + assertUpdate(sqlMergeCommand, 2); + + assertQuery("SELECT * FROM " + targetTable, + "VALUES ('Bill', 7, 'Buena'), ('Dave', 11, 'Devon')"); + } + finally { + assertUpdate("DROP TABLE " + targetTable); + } + } + + @Test(dataProvider = "partitionedProvider") + public void testMergeDeletePartitioned(String partitioning) + { + String targetTable = "merge_delete_partitioned_" + randomTableSuffix(); + try { + assertUpdate(format("CREATE TABLE %s (customer VARCHAR, purchases INT, address VARCHAR) %s", targetTable, partitioning)); + assertUpdate(format("INSERT INTO %s (customer, purchases, address) VALUES ('Aaron', 5, 'Antioch'), ('Bill', 7, 'Buena'), ('Carol', 3, 'Cambridge'), ('Dave', 11, 'Devon')", targetTable), 4); + + @Language("SQL") String sqlMergeCommand = + format("MERGE INTO %s t USING ", targetTable) + + "(VALUES ('Aaron', 6, 'Arches'), ('Carol', 9, 'Centreville'), ('Ed', 7, 'Etherville')) AS s(customer, purchases, address) " + + "ON (t.customer = s.customer) " + + "WHEN MATCHED THEN" + + " DELETE " + + "WHEN NOT MATCHED THEN" + + " INSERT (customer, purchases, address) VALUES(s.customer, s.purchases, s.address)"; + + assertUpdate(sqlMergeCommand, 3); + + assertQuery("SELECT * FROM " + targetTable, + "VALUES ('Bill', 7, 'Buena'), ('Dave', 11, 'Devon'), ('Ed', 7, 'Etherville')"); + } + finally { + assertUpdate("DROP TABLE " + targetTable); + } + } + @Test public void testMergeSimpleQueryPartitioned() { diff --git a/presto-main-base/src/main/java/com/facebook/presto/sql/analyzer/StatementAnalyzer.java b/presto-main-base/src/main/java/com/facebook/presto/sql/analyzer/StatementAnalyzer.java index da16ab653b12f..7914a8b62cf66 100644 --- a/presto-main-base/src/main/java/com/facebook/presto/sql/analyzer/StatementAnalyzer.java +++ b/presto-main-base/src/main/java/com/facebook/presto/sql/analyzer/StatementAnalyzer.java @@ -157,6 +157,7 @@ import com.facebook.presto.sql.tree.LongLiteral; import com.facebook.presto.sql.tree.Merge; import com.facebook.presto.sql.tree.MergeCase; +import com.facebook.presto.sql.tree.MergeDelete; import com.facebook.presto.sql.tree.MergeInsert; import com.facebook.presto.sql.tree.MergeUpdate; import com.facebook.presto.sql.tree.NaturalJoin; @@ -3546,6 +3547,13 @@ else if (mergeCase instanceof MergeInsert && setColumnNames.isEmpty()) { .ifPresent(mergeCase -> accessControl.checkCanInsertIntoTable(session.getRequiredTransactionId(), session.getIdentity(), session.getAccessControlContext(), targetTableQualifiedName)); + // Check if the user has permission to delete from the target table + merge.getMergeCases().stream() + .filter(mergeCase -> mergeCase instanceof MergeDelete) + .findFirst() + .ifPresent(mergeCase -> accessControl.checkCanDeleteFromTable(session.getRequiredTransactionId(), + session.getIdentity(), session.getAccessControlContext(), targetTableQualifiedName)); + // If there are any columns to update then verify the user has permission to update these columns. if (!allUpdateColumnNames.isEmpty()) { accessControl.checkCanUpdateTableColumns(session.getRequiredTransactionId(), session.getIdentity(), diff --git a/presto-main-base/src/main/java/com/facebook/presto/sql/planner/QueryPlanner.java b/presto-main-base/src/main/java/com/facebook/presto/sql/planner/QueryPlanner.java index c7deb6a31cdec..4a74d27e6d859 100644 --- a/presto-main-base/src/main/java/com/facebook/presto/sql/planner/QueryPlanner.java +++ b/presto-main-base/src/main/java/com/facebook/presto/sql/planner/QueryPlanner.java @@ -88,6 +88,7 @@ import com.facebook.presto.sql.tree.LongLiteral; import com.facebook.presto.sql.tree.Merge; import com.facebook.presto.sql.tree.MergeCase; +import com.facebook.presto.sql.tree.MergeDelete; import com.facebook.presto.sql.tree.MergeInsert; import com.facebook.presto.sql.tree.MergeUpdate; import com.facebook.presto.sql.tree.Node; @@ -135,6 +136,7 @@ import static com.facebook.presto.common.type.TinyintType.TINYINT; import static com.facebook.presto.common.type.VarbinaryType.VARBINARY; import static com.facebook.presto.common.type.VarcharType.VARCHAR; +import static com.facebook.presto.spi.ConnectorMergeSink.DELETE_OPERATION_NUMBER; import static com.facebook.presto.spi.ConnectorMergeSink.INSERT_OPERATION_NUMBER; import static com.facebook.presto.spi.ConnectorMergeSink.UPDATE_OPERATION_NUMBER; import static com.facebook.presto.spi.StandardErrorCode.INVALID_LIMIT_CLAUSE; @@ -727,6 +729,9 @@ private static Expression checkNotNullColumns( private static int getMergeCaseOperationNumber(MergeCase mergeCase) { + if (mergeCase instanceof MergeDelete) { + return DELETE_OPERATION_NUMBER; + } if (mergeCase instanceof MergeInsert) { return INSERT_OPERATION_NUMBER; } diff --git a/presto-parser/src/main/antlr4/com/facebook/presto/sql/parser/SqlBase.g4 b/presto-parser/src/main/antlr4/com/facebook/presto/sql/parser/SqlBase.g4 index cd7662dc4c8eb..4fd1773f35a81 100644 --- a/presto-parser/src/main/antlr4/com/facebook/presto/sql/parser/SqlBase.g4 +++ b/presto-parser/src/main/antlr4/com/facebook/presto/sql/parser/SqlBase.g4 @@ -557,6 +557,7 @@ mergeCase : WHEN MATCHED THEN UPDATE SET targetColumns+=identifier EQ values+=expression (',' targetColumns+=identifier EQ values+=expression)* #mergeUpdate + | WHEN MATCHED THEN DELETE #mergeDelete | WHEN NOT MATCHED THEN INSERT ('(' columns+=identifier (',' columns+=identifier)* ')')? VALUES '(' values+=expression (',' values+=expression)* ')' #mergeInsert diff --git a/presto-parser/src/main/java/com/facebook/presto/sql/SqlFormatter.java b/presto-parser/src/main/java/com/facebook/presto/sql/SqlFormatter.java index 36939784042c5..06fcf5dd6bbdf 100644 --- a/presto-parser/src/main/java/com/facebook/presto/sql/SqlFormatter.java +++ b/presto-parser/src/main/java/com/facebook/presto/sql/SqlFormatter.java @@ -74,6 +74,7 @@ import com.facebook.presto.sql.tree.LikeClause; import com.facebook.presto.sql.tree.Merge; import com.facebook.presto.sql.tree.MergeCase; +import com.facebook.presto.sql.tree.MergeDelete; import com.facebook.presto.sql.tree.MergeInsert; import com.facebook.presto.sql.tree.MergeUpdate; import com.facebook.presto.sql.tree.NaturalJoin; @@ -759,6 +760,14 @@ protected Void visitMergeUpdate(MergeUpdate node, Integer indent) return null; } + @Override + protected Void visitMergeDelete(MergeDelete node, Integer indent) + { + appendMergeCaseWhen(true); + append(indent + 1, "DELETE"); + return null; + } + private void appendMergeCaseWhen(boolean matched) { builder.append(matched ? "WHEN MATCHED" : "WHEN NOT MATCHED").append(" THEN\n"); diff --git a/presto-parser/src/main/java/com/facebook/presto/sql/parser/AstBuilder.java b/presto-parser/src/main/java/com/facebook/presto/sql/parser/AstBuilder.java index ede1ce82d9b64..6f00cd3042ebd 100644 --- a/presto-parser/src/main/java/com/facebook/presto/sql/parser/AstBuilder.java +++ b/presto-parser/src/main/java/com/facebook/presto/sql/parser/AstBuilder.java @@ -117,6 +117,7 @@ import com.facebook.presto.sql.tree.LongLiteral; import com.facebook.presto.sql.tree.Merge; import com.facebook.presto.sql.tree.MergeCase; +import com.facebook.presto.sql.tree.MergeDelete; import com.facebook.presto.sql.tree.MergeInsert; import com.facebook.presto.sql.tree.MergeUpdate; import com.facebook.presto.sql.tree.NaturalJoin; @@ -555,6 +556,12 @@ public Node visitMergeUpdate(SqlBaseParser.MergeUpdateContext context) return new MergeUpdate(getLocation(context), assignments.build()); } + @Override + public Node visitMergeDelete(SqlBaseParser.MergeDeleteContext context) + { + return new MergeDelete(getLocation(context)); + } + @Override public Node visitRenameTable(SqlBaseParser.RenameTableContext context) { diff --git a/presto-parser/src/main/java/com/facebook/presto/sql/tree/AstVisitor.java b/presto-parser/src/main/java/com/facebook/presto/sql/tree/AstVisitor.java index 17f444dfa6072..bb76598937052 100644 --- a/presto-parser/src/main/java/com/facebook/presto/sql/tree/AstVisitor.java +++ b/presto-parser/src/main/java/com/facebook/presto/sql/tree/AstVisitor.java @@ -552,6 +552,11 @@ protected R visitMergeUpdate(MergeUpdate node, C context) return visitMergeCase(node, context); } + protected R visitMergeDelete(MergeDelete node, C context) + { + return visitMergeCase(node, context); + } + protected R visitTableElement(TableElement node, C context) { return visitNode(node, context); diff --git a/presto-parser/src/main/java/com/facebook/presto/sql/tree/DefaultTraversalVisitor.java b/presto-parser/src/main/java/com/facebook/presto/sql/tree/DefaultTraversalVisitor.java index e58ea3f9a85af..f31e159b14fc3 100644 --- a/presto-parser/src/main/java/com/facebook/presto/sql/tree/DefaultTraversalVisitor.java +++ b/presto-parser/src/main/java/com/facebook/presto/sql/tree/DefaultTraversalVisitor.java @@ -513,6 +513,12 @@ protected R visitMergeUpdate(MergeUpdate node, C context) return null; } + @Override + protected R visitMergeDelete(MergeDelete node, C context) + { + return null; + } + @Override protected R visitCreateTableAsSelect(CreateTableAsSelect node, C context) { diff --git a/presto-parser/src/main/java/com/facebook/presto/sql/tree/MergeDelete.java b/presto-parser/src/main/java/com/facebook/presto/sql/tree/MergeDelete.java new file mode 100644 index 0000000000000..52bc30820e93d --- /dev/null +++ b/presto-parser/src/main/java/com/facebook/presto/sql/tree/MergeDelete.java @@ -0,0 +1,83 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.facebook.presto.sql.tree; + +import com.google.common.collect.ImmutableList; + +import java.util.List; +import java.util.Optional; + +public class MergeDelete + extends MergeCase +{ + public MergeDelete() + { + this(Optional.empty()); + } + + public MergeDelete(NodeLocation location) + { + this(Optional.of(location)); + } + + public MergeDelete(Optional location) + { + super(location); + } + + @Override + public R accept(AstVisitor visitor, C context) + { + return visitor.visitMergeDelete(this, context); + } + + @Override + public List getSetColumns() + { + return ImmutableList.of(); + } + + @Override + public List getSetExpressions() + { + return ImmutableList.of(); + } + + @Override + public List getChildren() + { + return ImmutableList.of(); + } + + @Override + public int hashCode() + { + return MergeDelete.class.hashCode(); + } + + @Override + public boolean equals(Object obj) + { + if (this == obj) { + return true; + } + return obj != null && getClass() == obj.getClass(); + } + + @Override + public String toString() + { + return "MergeDelete{}"; + } +} diff --git a/presto-parser/src/test/java/com/facebook/presto/sql/parser/TestSqlParser.java b/presto-parser/src/test/java/com/facebook/presto/sql/parser/TestSqlParser.java index 766e79f00556b..1c9438e565c79 100644 --- a/presto-parser/src/test/java/com/facebook/presto/sql/parser/TestSqlParser.java +++ b/presto-parser/src/test/java/com/facebook/presto/sql/parser/TestSqlParser.java @@ -99,6 +99,7 @@ import com.facebook.presto.sql.tree.LogicalBinaryExpression; import com.facebook.presto.sql.tree.LongLiteral; import com.facebook.presto.sql.tree.Merge; +import com.facebook.presto.sql.tree.MergeDelete; import com.facebook.presto.sql.tree.MergeInsert; import com.facebook.presto.sql.tree.MergeUpdate; import com.facebook.presto.sql.tree.NaturalJoin; @@ -1851,6 +1852,31 @@ ArithmeticBinaryExpression.Operator.ADD, nameReference("sales"), nameReference(" nameReference("ms", "sale_date"), nameReference("ms", "price")))))); } + @Test + public void testMergeDelete() + { + NodeLocation location = new NodeLocation(1, 1); + assertStatement("" + + "MERGE INTO product_sales AS s\n" + + " USING monthly_sales AS ms\n" + + " ON s.product_id = ms.product_id\n" + + "WHEN MATCHED THEN\n" + + " DELETE\n" + + "WHEN NOT MATCHED THEN\n" + + " INSERT (product_id, sales)\n" + + " VALUES (ms.product_id, ms.sales)", + new Merge( + location, + new AliasedRelation(location, table(QualifiedName.of("product_sales")), new Identifier("s"), null), + aliased(table(QualifiedName.of("monthly_sales")), "ms"), + equal(nameReference("s", "product_id"), nameReference("ms", "product_id")), + ImmutableList.of( + new MergeDelete(), + new MergeInsert( + ImmutableList.of(new Identifier("product_id"), new Identifier("sales")), + ImmutableList.of(nameReference("ms", "product_id"), nameReference("ms", "sales")))))); + } + @Test public void testRenameTable() {