HHH-16084 - MERGE (upsert) for optional table updates - H2

This commit is contained in:
Steve Ebersole 2023-01-24 01:31:46 -06:00
parent 998f2ef21f
commit 21b7745768
5 changed files with 360 additions and 2 deletions

View File

@ -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 );
}
}

View File

@ -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<T extends JdbcOperation> 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<ColumnValueBinding> valueBindings = tableUpsert.getValueBindings();
final List<ColumnValueBinding> 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<ColumnValueBinding> 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<ColumnValueBinding> valueBindings = tableUpsert.getValueBindings();
final List<ColumnValueBinding> 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<ColumnValueBinding> 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<ColumnValueBinding> 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" );
}
}
}

View File

@ -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<? extends JdbcParameterBinder> parameterBinders) {
super( tableDetails, mutationTarget, sql, false, Expectations.NONE, parameterBinders );
}
@Override
public MutationType getMutationType() {
return MutationType.UPDATE;
}
}

View File

@ -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;

View File

@ -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;
}
}
}