From 54d0ffa15874199f538ae81c1166c84b61f12fbb Mon Sep 17 00:00:00 2001 From: Jan Schatteman Date: Tue, 3 Jun 2025 23:55:25 +0200 Subject: [PATCH] HHH-19498 - improve upserts on MySQL and MariaDB Signed-off-by: Jan Schatteman --- .../org/hibernate/dialect/MariaDBDialect.java | 12 ++ .../dialect/MySQLDeleteOrUpsertOperation.java | 57 +++++++++ .../org/hibernate/dialect/MySQLDialect.java | 12 ++ .../sql/ast/MariaDBSqlAstTranslator.java | 20 +++- .../sql/ast/MySQLSqlAstTranslator.java | 31 ++++- ...AstTranslatorWithOnDuplicateKeyUpdate.java | 112 ++++++++++++++++++ .../model/jdbc/DeleteOrUpsertOperation.java | 10 +- .../orm/test/stateless/UpsertTest.java | 80 ++++++++++++- .../test/stateless/UpsertVersionedTest.java | 28 +++++ 9 files changed, 350 insertions(+), 12 deletions(-) create mode 100644 hibernate-core/src/main/java/org/hibernate/dialect/MySQLDeleteOrUpsertOperation.java create mode 100644 hibernate-core/src/main/java/org/hibernate/dialect/sql/ast/SqlAstTranslatorWithOnDuplicateKeyUpdate.java diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/MariaDBDialect.java b/hibernate-core/src/main/java/org/hibernate/dialect/MariaDBDialect.java index 923a436fb3fc..7d6959a18039 100644 --- a/hibernate-core/src/main/java/org/hibernate/dialect/MariaDBDialect.java +++ b/hibernate-core/src/main/java/org/hibernate/dialect/MariaDBDialect.java @@ -35,6 +35,7 @@ import org.hibernate.exception.spi.SQLExceptionConversionDelegate; import org.hibernate.exception.spi.TemplatedViolatedConstraintNameExtractor; import org.hibernate.exception.spi.ViolatedConstraintNameExtractor; +import org.hibernate.persister.entity.mutation.EntityMutationTarget; import org.hibernate.query.sqm.CastType; import org.hibernate.service.ServiceRegistry; import org.hibernate.sql.ast.SqlAstTranslator; @@ -42,6 +43,8 @@ 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.OptionalTableUpdate; import org.hibernate.tool.schema.extract.internal.SequenceInformationExtractorMariaDBDatabaseImpl; import org.hibernate.tool.schema.extract.spi.SequenceInformationExtractor; import org.hibernate.type.SqlTypes; @@ -417,4 +420,13 @@ public boolean supportsWithClauseInSubquery() { return false; } + @Override + public MutationOperation createOptionalTableUpdateOperation(EntityMutationTarget mutationTarget, OptionalTableUpdate optionalTableUpdate, SessionFactoryImplementor factory) { + if ( optionalTableUpdate.getNumberOfOptimisticLockBindings() == 0 ) { + final MariaDBSqlAstTranslator translator = new MariaDBSqlAstTranslator<>( factory, optionalTableUpdate, MariaDBDialect.this ); + return translator.createMergeOperation( optionalTableUpdate ); + } + return super.createOptionalTableUpdateOperation( mutationTarget, optionalTableUpdate, factory ); + } + } diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/MySQLDeleteOrUpsertOperation.java b/hibernate-core/src/main/java/org/hibernate/dialect/MySQLDeleteOrUpsertOperation.java new file mode 100644 index 000000000000..53d4a5cf9f87 --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/dialect/MySQLDeleteOrUpsertOperation.java @@ -0,0 +1,57 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.dialect; + + +import org.hibernate.StaleStateException; +import org.hibernate.engine.jdbc.mutation.JdbcValueBindings; +import org.hibernate.engine.spi.SharedSessionContractImplementor; +import org.hibernate.jdbc.Expectation; +import org.hibernate.persister.entity.mutation.EntityMutationTarget; +import org.hibernate.persister.entity.mutation.EntityTableMapping; +import org.hibernate.sql.model.ValuesAnalysis; +import org.hibernate.sql.model.internal.OptionalTableUpdate; +import org.hibernate.sql.model.jdbc.DeleteOrUpsertOperation; +import org.hibernate.sql.model.jdbc.UpsertOperation; + +import java.sql.PreparedStatement; + + +/** + * @author Jan Schatteman + */ +public class MySQLDeleteOrUpsertOperation extends DeleteOrUpsertOperation { + + private Expectation customExpectation; + + public MySQLDeleteOrUpsertOperation(EntityMutationTarget mutationTarget, EntityTableMapping tableMapping, UpsertOperation upsertOperation, OptionalTableUpdate optionalTableUpdate) { + super( mutationTarget, tableMapping, upsertOperation, optionalTableUpdate ); + } + + @Override + public void performMutation(JdbcValueBindings jdbcValueBindings, ValuesAnalysis valuesAnalysis, SharedSessionContractImplementor session) { + customExpectation = new MySQLRowCountExpectation(); + super.performMutation( jdbcValueBindings, valuesAnalysis, session ); + } + + @Override + protected Expectation getExpectation() { + return customExpectation; + } + + private static class MySQLRowCountExpectation implements Expectation { + @Override + public final void verifyOutcome(int rowCount, PreparedStatement statement, int batchPosition, String sql) { + if ( rowCount > 2 ) { + throw new StaleStateException( + "Unexpected row count" + + " (the expected row count for an ON DUPLICATE KEY UPDATE statement should be either 0, 1 or 2 )" + + " [" + sql + "]" + ); + } + } + } + +} diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/MySQLDialect.java b/hibernate-core/src/main/java/org/hibernate/dialect/MySQLDialect.java index 7e5c119076f6..e6bfb8f5ba81 100644 --- a/hibernate-core/src/main/java/org/hibernate/dialect/MySQLDialect.java +++ b/hibernate-core/src/main/java/org/hibernate/dialect/MySQLDialect.java @@ -45,6 +45,7 @@ import org.hibernate.mapping.CheckConstraint; import org.hibernate.metamodel.mapping.EntityMappingType; import org.hibernate.metamodel.spi.RuntimeModelCreationContext; +import org.hibernate.persister.entity.mutation.EntityMutationTarget; import org.hibernate.query.common.TemporalUnit; import org.hibernate.query.sqm.CastType; import org.hibernate.query.sqm.IntervalType; @@ -63,6 +64,8 @@ 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.OptionalTableUpdate; import org.hibernate.type.BasicTypeRegistry; import org.hibernate.type.NullType; import org.hibernate.type.SqlTypes; @@ -1668,4 +1671,13 @@ public boolean supportsRowValueConstructorSyntaxInQuantifiedPredicates() { return false; } + @Override + public MutationOperation createOptionalTableUpdateOperation(EntityMutationTarget mutationTarget, OptionalTableUpdate optionalTableUpdate, SessionFactoryImplementor factory) { + if ( optionalTableUpdate.getNumberOfOptimisticLockBindings() == 0 ) { + final MySQLSqlAstTranslator translator = new MySQLSqlAstTranslator<>( factory, optionalTableUpdate, MySQLDialect.this ); + return translator.createMergeOperation( optionalTableUpdate ); + } + return super.createOptionalTableUpdateOperation( mutationTarget, optionalTableUpdate, factory ); + } + } diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/sql/ast/MariaDBSqlAstTranslator.java b/hibernate-core/src/main/java/org/hibernate/dialect/sql/ast/MariaDBSqlAstTranslator.java index 481c9445ea81..8723969581e4 100644 --- a/hibernate-core/src/main/java/org/hibernate/dialect/sql/ast/MariaDBSqlAstTranslator.java +++ b/hibernate-core/src/main/java/org/hibernate/dialect/sql/ast/MariaDBSqlAstTranslator.java @@ -14,7 +14,6 @@ import org.hibernate.metamodel.mapping.JdbcMappingContainer; import org.hibernate.query.sqm.ComparisonOperator; import org.hibernate.sql.ast.Clause; -import org.hibernate.sql.ast.spi.AbstractSqlAstTranslator; import org.hibernate.sql.ast.tree.Statement; import org.hibernate.sql.ast.tree.delete.DeleteStatement; import org.hibernate.sql.ast.tree.expression.BinaryArithmeticExpression; @@ -38,13 +37,14 @@ import org.hibernate.sql.exec.internal.JdbcOperationQueryInsertImpl; import org.hibernate.sql.exec.spi.JdbcOperation; import org.hibernate.sql.exec.spi.JdbcOperationQueryInsert; +import org.hibernate.sql.model.ast.ColumnValueBinding; /** * A SQL AST translator for MariaDB. * * @author Christian Beikov */ -public class MariaDBSqlAstTranslator extends AbstractSqlAstTranslator { +public class MariaDBSqlAstTranslator extends SqlAstTranslatorWithOnDuplicateKeyUpdate { private final MariaDBDialect dialect; @@ -410,4 +410,20 @@ protected void renderStringContainsExactlyPredicate(Expression haystack, Express needle.accept( this ); appendSql( ",'~','~~'),'?','~?'),'%','~%'),'%') escape '~'" ); } + + /* + Upsert Template: (for an entity WITHOUT @Version) + INSERT INTO employees (id, name, salary, version) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = values(name), + salary = values(salary) + */ + @Override + protected void renderUpdatevalue(ColumnValueBinding columnValueBinding) { + appendSql( "values(" ); + appendSql( columnValueBinding.getColumnReference().getColumnExpression() ); + appendSql( ")" ); + } + } diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/sql/ast/MySQLSqlAstTranslator.java b/hibernate-core/src/main/java/org/hibernate/dialect/sql/ast/MySQLSqlAstTranslator.java index f4d714987de6..6058ea958483 100644 --- a/hibernate-core/src/main/java/org/hibernate/dialect/sql/ast/MySQLSqlAstTranslator.java +++ b/hibernate-core/src/main/java/org/hibernate/dialect/sql/ast/MySQLSqlAstTranslator.java @@ -12,7 +12,6 @@ import org.hibernate.internal.util.collections.Stack; import org.hibernate.query.sqm.ComparisonOperator; import org.hibernate.sql.ast.Clause; -import org.hibernate.sql.ast.spi.AbstractSqlAstTranslator; import org.hibernate.sql.ast.tree.Statement; import org.hibernate.sql.ast.tree.delete.DeleteStatement; import org.hibernate.sql.ast.tree.expression.BinaryArithmeticExpression; @@ -36,6 +35,7 @@ import org.hibernate.sql.ast.tree.select.SelectStatement; import org.hibernate.sql.ast.tree.update.UpdateStatement; import org.hibernate.sql.exec.spi.JdbcOperation; +import org.hibernate.sql.model.ast.ColumnValueBinding; import java.util.ArrayList; import java.util.List; @@ -46,7 +46,7 @@ * * @author Christian Beikov */ -public class MySQLSqlAstTranslator extends AbstractSqlAstTranslator { +public class MySQLSqlAstTranslator extends SqlAstTranslatorWithOnDuplicateKeyUpdate { /** * On MySQL, 1GB or {@code 2^30 - 1} is the maximum size that a char value can be casted. @@ -437,4 +437,31 @@ protected void renderStringContainsExactlyPredicate(Expression haystack, Express needle.accept( this ); appendSql( ",'~','~~'),'?','~?'),'%','~%'),'%') escape '~'" ); } + + /* + Upsert Template: (for an entity WITHOUT @Version) + INSERT INTO employees (id, name, salary, version) + VALUES (?, ?, ?, ?) AS tr + ON DUPLICATE KEY UPDATE + name = tr.name, + salary = tr.salary + */ + @Override + protected void renderNewRowAlias() { + appendSql( "as " ); + renderAlias(); + appendSql( " " ); + } + + @Override + protected void renderUpdatevalue(ColumnValueBinding columnValueBinding) { + renderAlias(); + appendSql( "." ); + appendSql( columnValueBinding.getColumnReference().getColumnExpression() ); + } + + private void renderAlias() { + appendSql( "tr" ); + } + } diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/sql/ast/SqlAstTranslatorWithOnDuplicateKeyUpdate.java b/hibernate-core/src/main/java/org/hibernate/dialect/sql/ast/SqlAstTranslatorWithOnDuplicateKeyUpdate.java new file mode 100644 index 000000000000..73fab31d26ca --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/dialect/sql/ast/SqlAstTranslatorWithOnDuplicateKeyUpdate.java @@ -0,0 +1,112 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.dialect.sql.ast; + + +import org.hibernate.dialect.MySQLDeleteOrUpsertOperation; +import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.hibernate.persister.entity.mutation.EntityTableMapping; +import org.hibernate.sql.ast.spi.SqlAstTranslatorWithUpsert; +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.ast.ColumnValueBinding; +import org.hibernate.sql.model.internal.OptionalTableUpdate; +import org.hibernate.sql.model.jdbc.UpsertOperation; + +import java.util.List; + +/** + * @author Jan Schatteman + */ +public class SqlAstTranslatorWithOnDuplicateKeyUpdate extends SqlAstTranslatorWithUpsert { + + public SqlAstTranslatorWithOnDuplicateKeyUpdate(SessionFactoryImplementor sessionFactory, Statement statement) { + super( sessionFactory, statement ); + } + + @Override + public MutationOperation createMergeOperation(OptionalTableUpdate optionalTableUpdate) { + assert optionalTableUpdate.getNumberOfOptimisticLockBindings() == 0; + + renderUpsertStatement( optionalTableUpdate ); + + final UpsertOperation upsertOperation = new UpsertOperation( + optionalTableUpdate.getMutatingTable().getTableMapping(), + optionalTableUpdate.getMutationTarget(), + getSql(), + getParameterBinders() + ); + + return new MySQLDeleteOrUpsertOperation( + optionalTableUpdate.getMutationTarget(), + (EntityTableMapping) optionalTableUpdate.getMutatingTable().getTableMapping(), + upsertOperation, + optionalTableUpdate + ); + } + + @Override + protected void renderUpsertStatement(OptionalTableUpdate optionalTableUpdate) { + renderInsertInto( optionalTableUpdate ); + appendSql( " " ); + renderOnDuplicateKeyUpdate( optionalTableUpdate ); + } + + protected void renderInsertInto(OptionalTableUpdate optionalTableUpdate) { + appendSql( "insert into " ); + appendSql( optionalTableUpdate.getMutatingTable().getTableName() ); + appendSql( " (" ); + + final List keyBindings = optionalTableUpdate.getKeyBindings(); + for ( ColumnValueBinding keyBinding : keyBindings ) { + appendSql( keyBinding.getColumnReference().getColumnExpression() ); + appendSql( ',' ); + } + + optionalTableUpdate.forEachValueBinding( (columnPosition, columnValueBinding) -> { + appendSql( columnValueBinding.getColumnReference().getColumnExpression() ); + if ( columnPosition != optionalTableUpdate.getValueBindings().size() - 1 ) { + appendSql( ',' ); + } + } ); + + appendSql( ") values (" ); + + for ( ColumnValueBinding keyBinding : keyBindings ) { + keyBinding.getValueExpression().accept( this ); + appendSql( ',' ); + } + + optionalTableUpdate.forEachValueBinding( (columnPosition, columnValueBinding) -> { + if ( columnPosition > 0 ) { + appendSql( ',' ); + } + columnValueBinding.getValueExpression().accept( this ); + } ); + appendSql(") "); + renderNewRowAlias(); + } + + protected void renderNewRowAlias() { + } + + protected void renderOnDuplicateKeyUpdate(OptionalTableUpdate optionalTableUpdate) { + appendSql( "on duplicate key update " ); + optionalTableUpdate.forEachValueBinding( (columnPosition, columnValueBinding) -> { + final String columnName = columnValueBinding.getColumnReference().getColumnExpression(); + if ( columnPosition > 0 ) { + appendSql( ',' ); + } + appendSql( columnName ); + append( " = " ); + renderUpdatevalue( columnValueBinding ); + } ); + } + + protected void renderUpdatevalue(ColumnValueBinding columnValueBinding) { + } + +} diff --git a/hibernate-core/src/main/java/org/hibernate/sql/model/jdbc/DeleteOrUpsertOperation.java b/hibernate-core/src/main/java/org/hibernate/sql/model/jdbc/DeleteOrUpsertOperation.java index c3fea08c8745..a17a7d8a561c 100644 --- a/hibernate-core/src/main/java/org/hibernate/sql/model/jdbc/DeleteOrUpsertOperation.java +++ b/hibernate-core/src/main/java/org/hibernate/sql/model/jdbc/DeleteOrUpsertOperation.java @@ -41,7 +41,7 @@ public class DeleteOrUpsertOperation implements SelfExecutingUpdateOperation { private final OptionalTableUpdate optionalTableUpdate; - private final Expectation expectation = new Expectation.RowCount(); + private final Expectation expectation = getExpectation(); public DeleteOrUpsertOperation( EntityMutationTarget mutationTarget, @@ -127,7 +127,7 @@ private void performDelete(JdbcValueBindings jdbcValueBindings, SharedSessionCon .executeUpdate( upsertDeleteStatement, statementDetails.getSqlString() ); MODEL_MUTATION_LOGGER.tracef( "`%s` rows upsert-deleted from `%s`", rowCount, tableMapping.getTableName() ); try { - expectation.verifyOutcome( rowCount, upsertDeleteStatement, -1, statementDetails.getSqlString() ); + getExpectation().verifyOutcome( rowCount, upsertDeleteStatement, -1, statementDetails.getSqlString() ); } catch (SQLException e) { throw jdbcServices.getSqlExceptionHelper().convert( @@ -203,7 +203,7 @@ private void performUpsert(JdbcValueBindings jdbcValueBindings, SharedSessionCon .executeUpdate( updateStatement, statementDetails.getSqlString() ); MODEL_MUTATION_LOGGER.tracef( "`%s` rows upserted into `%s`", rowCount, tableMapping.getTableName() ); try { - expectation.verifyOutcome( rowCount, updateStatement, -1, statementDetails.getSqlString() ); + getExpectation().verifyOutcome( rowCount, updateStatement, -1, statementDetails.getSqlString() ); } catch (SQLException e) { throw jdbcServices.getSqlExceptionHelper().convert( @@ -231,4 +231,8 @@ public UpsertOperation getUpsertOperation() { public OptionalTableUpdate getOptionalTableUpdate() { return optionalTableUpdate; } + + protected Expectation getExpectation() { + return new Expectation.RowCount(); + } } diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/stateless/UpsertTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/stateless/UpsertTest.java index b86e880e70b7..c18aa232c08e 100644 --- a/hibernate-core/src/test/java/org/hibernate/orm/test/stateless/UpsertTest.java +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/stateless/UpsertTest.java @@ -6,17 +6,25 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; +import org.hibernate.dialect.MariaDBDialect; +import org.hibernate.dialect.MySQLDialect; +import org.hibernate.testing.jdbc.SQLStatementInspector; import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.RequiresDialect; +import org.hibernate.testing.orm.junit.RequiresDialects; import org.hibernate.testing.orm.junit.SessionFactory; import org.hibernate.testing.orm.junit.SessionFactoryScope; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; -@SessionFactory +@SessionFactory(useCollectingStatementInspector = true) @DomainModel(annotatedClasses = UpsertTest.Record.class) public class UpsertTest { @Test void test(SessionFactoryScope scope) { + scope.getSessionFactory().getSchemaManager().truncate(); + scope.inStatelessTransaction(s-> { s.upsert(new Record(123L,"hello earth")); s.upsert(new Record(456L,"hello mars")); @@ -25,24 +33,86 @@ public class UpsertTest { assertEquals("hello earth", s.get( Record.class,123L).message); assertEquals("hello mars", s.get( Record.class,456L).message); }); - scope.inStatelessTransaction(s-> { - s.upsert(new Record(123L,"goodbye earth")); - }); + scope.inStatelessTransaction(s-> s.upsert(new Record(123L,"goodbye earth")) ); scope.inStatelessTransaction(s-> { assertEquals("goodbye earth", s.get( Record.class,123L).message); assertEquals("hello mars", s.get( Record.class,456L).message); }); } - @Entity + + @RequiresDialects( + value = { + @RequiresDialect( MySQLDialect.class ), + @RequiresDialect( MariaDBDialect.class ) + } + ) + @Test void testMySQL(SessionFactoryScope scope) { + scope.getSessionFactory().getSchemaManager().truncate(); + + SQLStatementInspector statementInspector = scope.getCollectingStatementInspector(); + statementInspector.clear(); + + scope.inStatelessTransaction(s-> { + s.upsert(new Record(123L,"hello earth")); + s.upsert(new Record(456L,"hello mars")); + }); + // Verify that only a single query is executed for each upsert, in contrast to the former update+insert + statementInspector.assertExecutedCount( 2 ); + + scope.inStatelessTransaction(s-> { + assertEquals("hello earth",s.get(Record.class,123L).message); + assertEquals("hello mars",s.get(Record.class,456L).message); + }); + statementInspector.clear(); + + scope.inStatelessTransaction(s-> s.upsert(new Record(123L,"goodbye earth")) ); + statementInspector.assertExecutedCount( 1 ); + + scope.inStatelessTransaction(s-> { + assertEquals("goodbye earth",s.get(Record.class,123L).message); + assertEquals("hello mars",s.get(Record.class,456L).message); + }); + } + + @RequiresDialects( + value = { + @RequiresDialect( MySQLDialect.class ), + @RequiresDialect( MariaDBDialect.class ) + } + ) + @Test void testMySQLRowCounts(SessionFactoryScope scope) { + scope.getSessionFactory().getSchemaManager().truncate(); + + // insert => rowcount 1 + scope.inStatelessTransaction(s-> assertDoesNotThrow(() -> s.upsert(new Record(123L,"hello earth", 321))) ); + + // Partial update => rowcount 2 + scope.inStatelessTransaction(s-> assertDoesNotThrow(() -> s.upsert(new Record(123L,"goodbye earth"))) ); + + // Nothing updated => rowcount 1 (?) + scope.inStatelessTransaction(s-> assertDoesNotThrow(() -> s.upsert(new Record(123L,"goodbye earth"))) ); + + // all null => delete + scope.inStatelessTransaction(s-> assertDoesNotThrow(() -> s.upsert(new Record(123L,null, null))) ); + } + + @Entity(name = "Record") static class Record { @Id Long id; String message; + Integer someInt; Record(Long id, String message) { this.id = id; this.message = message; } + Record(Long id, String message, Integer someInt) { + this.id = id; + this.message = message; + this.someInt = someInt; + } + Record() { } } diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/stateless/UpsertVersionedTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/stateless/UpsertVersionedTest.java index 0c2b71a64a60..dbbe471114d5 100644 --- a/hibernate-core/src/test/java/org/hibernate/orm/test/stateless/UpsertVersionedTest.java +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/stateless/UpsertVersionedTest.java @@ -8,12 +8,18 @@ import jakarta.persistence.Id; import jakarta.persistence.Version; import org.hibernate.StaleStateException; +import org.hibernate.dialect.MariaDBDialect; +import org.hibernate.dialect.MySQLDialect; import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.RequiresDialect; +import org.hibernate.testing.orm.junit.RequiresDialects; import org.hibernate.testing.orm.junit.SessionFactory; import org.hibernate.testing.orm.junit.SessionFactoryScope; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.fail; @SessionFactory @@ -68,6 +74,28 @@ public class UpsertVersionedTest { } ); } + @RequiresDialects( + value = { + @RequiresDialect( MySQLDialect.class ), + @RequiresDialect( MariaDBDialect.class ) + } + ) + @Test void testMySQLRowCounts(SessionFactoryScope scope) { + scope.getSessionFactory().getSchemaManager().truncate(); + + // insert => rowcount 1 + scope.inStatelessTransaction(s-> assertDoesNotThrow(() -> s.upsert(new Record(123L, null, "hello earth"))) ); + + // Partial update => rowcount 2 + scope.inStatelessTransaction(s-> assertDoesNotThrow(() -> s.upsert(new Record(123L,0L,"goodbye earth"))) ); + + // Only version updated rowcount 2 + scope.inStatelessTransaction(s-> assertDoesNotThrow(() -> s.upsert(new Record(123L, 1L, "goodbye earth"))) ); + + // Stale upsert, should throw StaleStateException + scope.inStatelessTransaction(s-> assertThrows(StaleStateException.class, () -> s.upsert(new Record(123L,null, null))) ); + } + @Entity(name = "Record") static class Record { @Id Long id;