diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/H2Dialect.java b/hibernate-core/src/main/java/org/hibernate/dialect/H2Dialect.java index 5239e1e2f7..19b4e833a7 100644 --- a/hibernate-core/src/main/java/org/hibernate/dialect/H2Dialect.java +++ b/hibernate-core/src/main/java/org/hibernate/dialect/H2Dialect.java @@ -43,6 +43,7 @@ import org.hibernate.exception.spi.ViolatedConstraintNameExtractor; import org.hibernate.internal.util.JdbcExceptionHelper; import org.hibernate.metamodel.mapping.EntityMappingType; import org.hibernate.metamodel.spi.RuntimeModelCreationContext; +import org.hibernate.persister.entity.mutation.EntityMutationTarget; import org.hibernate.query.sqm.FetchClauseType; import org.hibernate.query.sqm.IntervalType; import org.hibernate.query.sqm.NullOrdering; @@ -60,6 +61,8 @@ import org.hibernate.sql.ast.spi.SqlAppender; import org.hibernate.sql.ast.spi.StandardSqlAstTranslatorFactory; import org.hibernate.sql.ast.tree.Statement; import org.hibernate.sql.exec.spi.JdbcOperation; +import org.hibernate.sql.model.MutationOperation; +import org.hibernate.sql.model.internal.TableUpsert; import org.hibernate.tool.schema.extract.internal.SequenceInformationExtractorH2DatabaseImpl; import org.hibernate.tool.schema.extract.internal.SequenceInformationExtractorLegacyImpl; import org.hibernate.tool.schema.extract.spi.SequenceInformationExtractor; @@ -867,4 +870,13 @@ public class H2Dialect extends Dialect { public int rowIdSqlType() { return BIGINT; } + + @Override + public MutationOperation createUpsertOperation( + EntityMutationTarget mutationTarget, + TableUpsert tableUpsert, + SessionFactoryImplementor factory) { + final H2SqlAstTranslator translator = new H2SqlAstTranslator<>( factory, tableUpsert ); + return translator.visitUpsert( tableUpsert ); + } } diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/H2SqlAstTranslator.java b/hibernate-core/src/main/java/org/hibernate/dialect/H2SqlAstTranslator.java index 6f2a500cce..74fbcd1fbd 100644 --- a/hibernate-core/src/main/java/org/hibernate/dialect/H2SqlAstTranslator.java +++ b/hibernate-core/src/main/java/org/hibernate/dialect/H2SqlAstTranslator.java @@ -35,7 +35,11 @@ import org.hibernate.sql.ast.tree.predicate.LikePredicate; import org.hibernate.sql.ast.tree.select.QueryPart; import org.hibernate.sql.ast.tree.select.SelectClause; import org.hibernate.sql.exec.spi.JdbcOperation; +import org.hibernate.sql.model.MutationOperation; +import org.hibernate.sql.model.ast.ColumnValueBinding; import org.hibernate.sql.model.internal.TableInsertStandard; +import org.hibernate.sql.model.internal.TableUpsert; +import org.hibernate.sql.model.jdbc.UpsertOperation; import static org.hibernate.internal.util.collections.CollectionHelper.isNotEmpty; @@ -307,4 +311,162 @@ public class H2SqlAstTranslator extends AbstractSqlAstT // Introduction of PERCENT support https://github.com/h2database/h2database/commit/f45913302e5f6ad149155a73763c0c59d8205849 return getDialect().getVersion().isSameOrAfter( 1, 4, 198 ); } + + public MutationOperation visitUpsert(TableUpsert tableUpsert) { + // template: + // + // merge into [table] as t + // using values([bindings]) as s ([column-names]) + // on t.[key] = s.[key] + // and t.[key] = ? + // when not matched + // then insert ... + // when matched + // and s.[columns] is null + // then delete + // when matched + // then update set ... + + renderMergeInto( tableUpsert ); + appendSql( " " ); + renderMergeUsing( tableUpsert ); + appendSql( " " ); + renderMergeOn( tableUpsert ); + appendSql( " " ); + renderMergeInsert( tableUpsert ); + appendSql( " " ); + renderMergeDelete( tableUpsert ); + appendSql( " " ); + renderMergeUpdate( tableUpsert ); + + return new UpsertOperation( + tableUpsert.getMutatingTable().getTableMapping(), + tableUpsert.getMutationTarget(), + getSql(), + getParameterBinders() + ); + } + + private void renderMergeInto(TableUpsert tableUpsert) { + appendSql( "merge into " ); + appendSql( tableUpsert.getMutatingTable().getTableName() ); + appendSql( " as t" ); + } + + private void renderMergeUsing(TableUpsert tableUpsert) { + final List valueBindings = tableUpsert.getValueBindings(); + final List keyBindings = tableUpsert.getKeyBindings(); + + appendSql( "using values (" ); + + for ( int i = 0; i < keyBindings.size(); i++ ) { + final ColumnValueBinding keyBinding = keyBindings.get( i ); + if ( i > 0 ) { + appendSql( ", " ); + } + keyBinding.getValueExpression().accept( this ); + } + for ( int i = 0; i < valueBindings.size(); i++ ) { + appendSql( ", " ); + final ColumnValueBinding valueBinding = valueBindings.get( i ); + valueBinding.getValueExpression().accept( this ); + } + + appendSql( ") as s(" ); + + for ( int i = 0; i < keyBindings.size(); i++ ) { + final ColumnValueBinding keyBinding = keyBindings.get( i ); + if ( i > 0 ) { + appendSql( ", " ); + } + appendSql( keyBinding.getColumnReference().getColumnExpression() ); + } + for ( int i = 0; i < valueBindings.size(); i++ ) { + appendSql( ", " ); + final ColumnValueBinding valueBinding = valueBindings.get( i ); + appendSql( valueBinding.getColumnReference().getColumnExpression() ); + } + + appendSql( ")" ); + } + + private void renderMergeOn(TableUpsert tableUpsert) { + appendSql( "on " ); + + // todo : optimistic locks? + + final List keyBindings = tableUpsert.getKeyBindings(); + for ( int i = 0; i < keyBindings.size(); i++ ) { + final ColumnValueBinding keyBinding = keyBindings.get( i ); + if ( i > 0 ) { + appendSql( " and " ); + } + keyBinding.getColumnReference().appendReadExpression( this, "t" ); + appendSql( "=" ); + keyBinding.getColumnReference().appendReadExpression( this, "s" ); + } + } + + private void renderMergeInsert(TableUpsert tableUpsert) { + final List valueBindings = tableUpsert.getValueBindings(); + final List keyBindings = tableUpsert.getKeyBindings(); + + appendSql( "when not matched then insert (" ); + for ( int i = 0; i < keyBindings.size(); i++ ) { + if ( i > 0 ) { + appendSql( ", " ); + } + final ColumnValueBinding keyBinding = keyBindings.get( i ); + appendSql( keyBinding.getColumnReference().getColumnExpression() ); + } + for ( int i = 0; i < valueBindings.size(); i++ ) { + appendSql( ", " ); + final ColumnValueBinding valueBinding = valueBindings.get( i ); + appendSql( valueBinding.getColumnReference().getColumnExpression() ); + } + + appendSql( ") values (" ); + for ( int i = 0; i < keyBindings.size(); i++ ) { + if ( i > 0 ) { + appendSql( ", " ); + } + final ColumnValueBinding keyBinding = keyBindings.get( i ); + keyBinding.getColumnReference().appendReadExpression( this, "s" ); + } + for ( int i = 0; i < valueBindings.size(); i++ ) { + appendSql( ", " ); + final ColumnValueBinding valueBinding = valueBindings.get( i ); + valueBinding.getColumnReference().appendReadExpression( this, "s" ); + } + + appendSql( ")" ); + } + + private void renderMergeDelete(TableUpsert tableUpsert) { + final List valueBindings = tableUpsert.getValueBindings(); + + appendSql( " when matched " ); + for ( int i = 0; i < valueBindings.size(); i++ ) { + final ColumnValueBinding binding = valueBindings.get( i ); + appendSql( " and " ); + binding.getColumnReference().appendReadExpression( this, "s" ); + appendSql( " is null" ); + } + appendSql( " then delete" ); + } + + private void renderMergeUpdate(TableUpsert tableUpsert) { + final List valueBindings = tableUpsert.getValueBindings(); + + appendSql( " when matched then update set " ); + for ( int i = 0; i < valueBindings.size(); i++ ) { + final ColumnValueBinding binding = valueBindings.get( i ); + if ( i > 0 ) { + appendSql( ", " ); + } + binding.getColumnReference().appendColumnForWrite( this, "t" ); + appendSql( "=" ); + binding.getColumnReference().appendColumnForWrite( this, "s" ); + } + } } diff --git a/hibernate-core/src/main/java/org/hibernate/sql/model/jdbc/UpsertOperation.java b/hibernate-core/src/main/java/org/hibernate/sql/model/jdbc/UpsertOperation.java new file mode 100644 index 0000000000..cc820dd314 --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/sql/model/jdbc/UpsertOperation.java @@ -0,0 +1,37 @@ +/* + * Hibernate, Relational Persistence for Idiomatic Java + * + * License: GNU Lesser General Public License (LGPL), version 2.1 or later. + * See the lgpl.txt file in the root directory or http://www.gnu.org/licenses/lgpl-2.1.html. + */ +package org.hibernate.sql.model.jdbc; + +import java.util.List; + +import org.hibernate.jdbc.Expectations; +import org.hibernate.sql.exec.spi.JdbcParameterBinder; +import org.hibernate.sql.model.MutationTarget; +import org.hibernate.sql.model.MutationType; +import org.hibernate.sql.model.TableMapping; + +/** + * JdbcMutation implementation for MERGE/UPSERT handling + * + * @author Steve Ebersole + */ +public class UpsertOperation extends AbstractJdbcMutation { + public UpsertOperation( + TableMapping tableDetails, + MutationTarget mutationTarget, + String sql, + List parameterBinders) { + super( tableDetails, mutationTarget, sql, false, Expectations.NONE, parameterBinders ); + } + + @Override + public MutationType getMutationType() { + return MutationType.UPDATE; + } + + +} diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/secondarytable/SecondaryRowTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/secondarytable/SecondaryRowTest.java index 9a1a18e37b..2481dd2b3e 100644 --- a/hibernate-core/src/test/java/org/hibernate/orm/test/secondarytable/SecondaryRowTest.java +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/secondarytable/SecondaryRowTest.java @@ -1,12 +1,15 @@ package org.hibernate.orm.test.secondarytable; +import java.time.LocalDateTime; + +import org.hibernate.dialect.H2Dialect; + import org.hibernate.testing.orm.junit.DomainModel; import org.hibernate.testing.orm.junit.SessionFactory; import org.hibernate.testing.orm.junit.SessionFactoryScope; +import org.hibernate.testing.orm.junit.SkipForDialect; import org.junit.jupiter.api.Test; -import java.time.LocalDateTime; - import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -15,7 +18,17 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @SessionFactory public class SecondaryRowTest { @Test + @SkipForDialect( + dialectClass = H2Dialect.class, + reason = "This test relies on SQL execution counts which is based on the legacy multi-statement solution. " + + "HHH-16084 adds support for H2 MERGE which handles those cases in one statement, so the counts are off. " + + "Need to change this test to physically check the number of rows. " + + "See e.g. org.hibernate.orm.test.write.UpsertTests" + ) public void testSecondaryRow(SessionFactoryScope scope) { + // we need to check the actual number of rows. + // because we now support merge/upsert SQL statements, the + // because HHH-16084 implements support for usage int seq = scope.getSessionFactory().getJdbcServices().getDialect().getSequenceSupport().supportsSequences() ? 1 : 0; diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/write/UpsertTests.java b/hibernate-core/src/test/java/org/hibernate/orm/test/write/UpsertTests.java new file mode 100644 index 0000000000..e1b886797e --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/write/UpsertTests.java @@ -0,0 +1,134 @@ +/* + * Hibernate, Relational Persistence for Idiomatic Java + * + * License: GNU Lesser General Public License (LGPL), version 2.1 or later. + * See the lgpl.txt file in the root directory or http://www.gnu.org/licenses/lgpl-2.1.html. + */ +package org.hibernate.orm.test.write; + +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.SessionFactory; +import org.hibernate.testing.orm.junit.SessionFactoryScope; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import jakarta.persistence.Basic; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.SecondaryTable; +import jakarta.persistence.Table; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Steve Ebersole + */ +@DomainModel(annotatedClasses = UpsertTests.TheEntity.class) +@SessionFactory +public class UpsertTests { + @Test + void testUpsertInsert(SessionFactoryScope scope) { + scope.inTransaction( (session) -> { + session.persist( new TheEntity( 1, "name", null ) ); + } ); + + verifySecondaryRows( scope, 0 ); + + scope.inTransaction( (session) -> { + final TheEntity loaded = session.byId( TheEntity.class ).load( 1 ); + loaded.setDetails( "non-null" ); + } ); + + verifySecondaryRows( scope, 1 ); + } + + private void verifySecondaryRows(SessionFactoryScope scope, int expectedCount) { + scope.inTransaction( (session) -> { + final int count = session.createNativeQuery( "select count(1) from supplements", Integer.class ).getSingleResult(); + assertThat( count ).isEqualTo( expectedCount ); + } ); + } + + @Test + void testUpsertUpdate(SessionFactoryScope scope) { + scope.inTransaction( (session) -> { + session.persist( new TheEntity( 1, "name", "non-null" ) ); + } ); + + verifySecondaryRows( scope, 1 ); + + scope.inTransaction( (session) -> { + final TheEntity loaded = session.byId( TheEntity.class ).load( 1 ); + loaded.setDetails( "non-non-null" ); + } ); + + verifySecondaryRows( scope, 1 ); + } + + @Test + void testUpsertDelete(SessionFactoryScope scope) { + scope.inTransaction( (session) -> { + session.persist( new TheEntity( 1, "name", "non-null" ) ); + } ); + + verifySecondaryRows( scope, 1 ); + + scope.inTransaction( (session) -> { + final TheEntity loaded = session.byId( TheEntity.class ).load( 1 ); + loaded.setDetails( null ); + } ); + + verifySecondaryRows( scope, 0 ); + } + + @AfterEach + void cleanUpTestData(SessionFactoryScope scope) { + scope.inTransaction( (session) -> { + session.createMutationQuery( "delete TheEntity" ).executeUpdate(); + } ); + } + + @Entity( name = "TheEntity" ) + @Table( name = "entities" ) + @SecondaryTable( name = "supplements" ) + public static class TheEntity { + @Id + private Integer id; + @Basic + private String name; + @Basic + @Column( table = "supplements" ) + private String details; + + private TheEntity() { + // for use by Hibernate + } + + public TheEntity(Integer id, String name, String details) { + this.id = id; + this.name = name; + this.details = details; + } + + public Integer getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDetails() { + return details; + } + + public void setDetails(String details) { + this.details = details; + } + } +}