diff --git a/hibernate-core/src/main/java/org/hibernate/jdbc/Expectation.java b/hibernate-core/src/main/java/org/hibernate/jdbc/Expectation.java index 2ce11631cb6e..d55a4ab7f127 100644 --- a/hibernate-core/src/main/java/org/hibernate/jdbc/Expectation.java +++ b/hibernate-core/src/main/java/org/hibernate/jdbc/Expectation.java @@ -1,243 +1,243 @@ -/* - * 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 . - */ -package org.hibernate.jdbc; - -import java.sql.CallableStatement; -import java.sql.PreparedStatement; -import java.sql.SQLException; -import java.sql.Types; - -import org.hibernate.HibernateException; -import org.hibernate.MappingException; -import org.hibernate.exception.GenericJDBCException; - -import static org.hibernate.jdbc.Expectations.checkBatched; -import static org.hibernate.jdbc.Expectations.checkNonBatched; -import static org.hibernate.jdbc.Expectations.sqlExceptionHelper; -import static org.hibernate.jdbc.Expectations.toCallableStatement; - -/** - * Defines an expected DML operation outcome. - * Used to verify that a JDBC operation completed successfully. - *

- * The two standard implementations are {@link RowCount} for - * row count checking, and {@link OutParameter} for checking - * the return code assigned to an output parameter of a - * {@link CallableStatement}. Custom implementations are - * permitted. - *

- * An {@code Expectation} is usually selected via an annotation, - * for example: - *

- * @Entity
- * @SQLUpdate(sql = "update Record set uid = gen_random_uuid(), whatever = ? where id = ?",
- *            verify = Expectation.RowCount.class)
- * class Record { ... }
- * 
- * - * @see org.hibernate.annotations.SQLInsert#verify - * @see org.hibernate.annotations.SQLUpdate#verify - * @see org.hibernate.annotations.SQLDelete#verify - * @see org.hibernate.annotations.SQLDeleteAll#verify - * - * @author Steve Ebersole - */ -public interface Expectation { - - /** - * Is it acceptable to combine this expectation with JDBC - * {@linkplain PreparedStatement#executeBatch() statement batching}? - * If this method returns {@code false}, the use of batch updates - * is disabled. - * - * @return True if batching can be combined with this expectation; - * false otherwise. - * - * @see PreparedStatement#executeBatch() - */ - default boolean canBeBatched() { - return true; - } - - /** - * The number of JDBC parameters this expectation uses. For example, - * {@link OutParameter} requires a single OUT parameter for reading - * back the number of affected rows. - */ - default int getNumberOfParametersUsed() { - return 0; - } - - /** - * Perform verification of the outcome of the JDBC operation based - * on the type of expectation defined, after execution of the given - * {@link PreparedStatement}. When a SQL statement is executed via - * {@link PreparedStatement#executeUpdate()}, {@code verifyOutcome()} - * is called exactly once. When {@link PreparedStatement#executeBatch()} - * is used to execute a batch update, this method is called once for - * each element of the batch. - * - * - * @param rowCount The RDBMS reported "number of rows affected" - * @param statement The statement representing the operation - * @param batchPosition The position in the batch (if batching), - * or {@code -1} if not part of a batch - * @param sql The SQL backing the prepared statement, for error - * reporting and logging purposes - * @throws SQLException Exception from the JDBC driver. - * @throws HibernateException Problem processing the outcome. - * - * @see PreparedStatement#executeUpdate() - * @see PreparedStatement#executeBatch() - */ - void verifyOutcome(int rowCount, PreparedStatement statement, int batchPosition, String sql) - throws SQLException, HibernateException; - - /** - * Perform any special statement preparation, for example, - * registration of OUT parameters. - * - * @param statement The statement to be prepared - * @return The number of bind positions consumed (if any) - * @throws SQLException Exception from the JDBC driver - * @throws HibernateException Problem performing preparation. - * - * @see CallableStatement#registerOutParameter(int, int) - */ - default int prepare(PreparedStatement statement) - throws SQLException, HibernateException { - return 0; - } - - /** - * Check that this implementation is compatible with the kind of - * {@link PreparedStatement} it will be called with. Implementors - * should throw a {@link MappingException} if the configuration - * is not supported. This operation is called when Hibernate - * starts up, so that incompatibilities are detected and reported - * as early as possible. - * - * @param callable true if this {@code Expectation} will be called - * with a {@link CallableStatement}. - * - * @since 6.5 - */ - default void validate(boolean callable) throws MappingException { - } - - /** - * No return code checking. Might mean that no checks are required, or that - * failure is indicated by a {@link java.sql.SQLException} being thrown, for - * example, by a {@link java.sql.CallableStatement stored procedure} which - * performs explicit checks. - * - * @since 6.5 - */ - class None implements Expectation { - @Override - public void verifyOutcome(int rowCount, PreparedStatement statement, int batchPosition, String sql) { - // nothing to do - } - } - - /** - * Row count checking. A row count is an integer value returned by - * {@link java.sql.PreparedStatement#executeUpdate()} or - * {@link java.sql.Statement#executeBatch()}. The row count is checked - * against an expected value. For example, the expected row count for - * an {@code INSERT} statement is always 1. - * - * @since 6.5 - */ - class RowCount implements Expectation { - @Override - public final void verifyOutcome(int rowCount, PreparedStatement statement, int batchPosition, String sql) { - if ( batchPosition < 0 ) { - checkNonBatched( expectedRowCount(), rowCount, sql ); - } - else { - checkBatched( expectedRowCount(), rowCount, batchPosition, sql ); - } - } - - protected int expectedRowCount() { - return 1; - } - } - - /** - * Essentially identical to {@link RowCount} except that the row count - * is obtained via an output parameter of a {@link CallableStatement - * stored procedure}. - *

- * Statement batching is disabled when {@code OutParameter} is used. - * - * @since 6.5 - */ - class OutParameter implements Expectation { - @Override - public final void verifyOutcome(int rowCount, PreparedStatement statement, int batchPosition, String sql) { - final int result; - try { - result = toCallableStatement( statement ).getInt( parameterIndex() ); - } - catch ( SQLException sqle ) { - sqlExceptionHelper.logExceptions( sqle, "Could not extract row count from CallableStatement" ); - throw new GenericJDBCException( "Could not extract row count from CallableStatement", sqle ); - } - if ( batchPosition < 0 ) { - checkNonBatched( expectedRowCount(), result, sql ); - } - else { - checkBatched( expectedRowCount(), result, batchPosition, sql ); - } - } - - @Override - public void validate(boolean callable) throws MappingException { - if ( !callable ) { - throw new MappingException( "Expectation.OutParameter operates exclusively on CallableStatements" ); - } - } - - @Override - public int getNumberOfParametersUsed() { - return 1; - } - - @Override - public int prepare(PreparedStatement statement) throws SQLException, HibernateException { - toCallableStatement( statement ).registerOutParameter( parameterIndex(), Types.NUMERIC ); - return 1; - } - - @Override - public boolean canBeBatched() { - return false; - } - - protected int parameterIndex() { - return 1; - } - - protected int expectedRowCount() { - return 1; - } - } - -} +/* + * 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 . + */ +package org.hibernate.jdbc; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Types; + +import org.hibernate.HibernateException; +import org.hibernate.MappingException; +import org.hibernate.exception.GenericJDBCException; + +import static org.hibernate.jdbc.Expectations.checkBatched; +import static org.hibernate.jdbc.Expectations.checkNonBatched; +import static org.hibernate.jdbc.Expectations.sqlExceptionHelper; +import static org.hibernate.jdbc.Expectations.toCallableStatement; + +/** + * Defines an expected DML operation outcome. + * Used to verify that a JDBC operation completed successfully. + *

+ * The two standard implementations are {@link RowCount} for + * row count checking, and {@link OutParameter} for checking + * the return code assigned to an output parameter of a + * {@link CallableStatement}. Custom implementations are + * permitted. + *

+ * An {@code Expectation} is usually selected via an annotation, + * for example: + *

+ * @Entity
+ * @SQLUpdate(sql = "update Record set uid = gen_random_uuid(), whatever = ? where id = ?",
+ *            verify = Expectation.RowCount.class)
+ * class Record { ... }
+ * 
+ * + * @see org.hibernate.annotations.SQLInsert#verify + * @see org.hibernate.annotations.SQLUpdate#verify + * @see org.hibernate.annotations.SQLDelete#verify + * @see org.hibernate.annotations.SQLDeleteAll#verify + * + * @author Steve Ebersole + */ +public interface Expectation { + + /** + * Is it acceptable to combine this expectation with JDBC + * {@linkplain PreparedStatement#executeBatch() statement batching}? + * If this method returns {@code false}, the use of batch updates + * is disabled. + * + * @return True if batching can be combined with this expectation; + * false otherwise. + * + * @see PreparedStatement#executeBatch() + */ + default boolean canBeBatched() { + return true; + } + + /** + * The number of JDBC parameters this expectation uses. For example, + * {@link OutParameter} requires a single OUT parameter for reading + * back the number of affected rows. + */ + default int getNumberOfParametersUsed() { + return 0; + } + + /** + * Perform verification of the outcome of the JDBC operation based + * on the type of expectation defined, after execution of the given + * {@link PreparedStatement}. When a SQL statement is executed via + * {@link PreparedStatement#executeUpdate()}, {@code verifyOutcome()} + * is called exactly once. When {@link PreparedStatement#executeBatch()} + * is used to execute a batch update, this method is called once for + * each element of the batch. + * + * + * @param rowCount The RDBMS reported "number of rows affected" + * @param statement The statement representing the operation + * @param batchPosition The position in the batch (if batching), + * or {@code -1} if not part of a batch + * @param sql The SQL backing the prepared statement, for error + * reporting and logging purposes + * @throws SQLException Exception from the JDBC driver. + * @throws HibernateException Problem processing the outcome. + * + * @see PreparedStatement#executeUpdate() + * @see PreparedStatement#executeBatch() + */ + void verifyOutcome(int rowCount, PreparedStatement statement, int batchPosition, String sql) + throws SQLException, HibernateException; + + /** + * Perform any special statement preparation, for example, + * registration of OUT parameters. + * + * @param statement The statement to be prepared + * @return The number of bind positions consumed (if any) + * @throws SQLException Exception from the JDBC driver + * @throws HibernateException Problem performing preparation. + * + * @see CallableStatement#registerOutParameter(int, int) + */ + default int prepare(PreparedStatement statement) + throws SQLException, HibernateException { + return 0; + } + + /** + * Check that this implementation is compatible with the kind of + * {@link PreparedStatement} it will be called with. Implementors + * should throw a {@link MappingException} if the configuration + * is not supported. This operation is called when Hibernate + * starts up, so that incompatibilities are detected and reported + * as early as possible. + * + * @param callable true if this {@code Expectation} will be called + * with a {@link CallableStatement}. + * + * @since 6.5 + */ + default void validate(boolean callable) throws MappingException { + } + + /** + * No return code checking. Might mean that no checks are required, or that + * failure is indicated by a {@link java.sql.SQLException} being thrown, for + * example, by a {@link java.sql.CallableStatement stored procedure} which + * performs explicit checks. + * + * @since 6.5 + */ + class None implements Expectation { + @Override + public void verifyOutcome(int rowCount, PreparedStatement statement, int batchPosition, String sql) { + // nothing to do + } + } + + /** + * Row count checking. A row count is an integer value returned by + * {@link java.sql.PreparedStatement#executeUpdate()} or + * {@link java.sql.Statement#executeBatch()}. The row count is checked + * against an expected value. For example, the expected row count for + * an {@code INSERT} statement is always 1. + * + * @since 6.5 + */ + class RowCount implements Expectation { + @Override + public final void verifyOutcome(int rowCount, PreparedStatement statement, int batchPosition, String sql) { + if ( batchPosition < 0 ) { + checkNonBatched( expectedRowCount(), rowCount, sql ); + } + else { + checkBatched( expectedRowCount(), rowCount, batchPosition, sql ); + } + } + + protected int expectedRowCount() { + return 1; + } + } + + /** + * Essentially identical to {@link RowCount} except that the row count + * is obtained via an output parameter of a {@linkplain CallableStatement + * stored procedure}. + *

+ * Statement batching is disabled when {@code OutParameter} is used. + * + * @since 6.5 + */ + class OutParameter implements Expectation { + @Override + public final void verifyOutcome(int rowCount, PreparedStatement statement, int batchPosition, String sql) { + final int result; + try { + result = toCallableStatement( statement ).getInt( parameterIndex() ); + } + catch ( SQLException sqle ) { + sqlExceptionHelper.logExceptions( sqle, "Could not extract row count from CallableStatement" ); + throw new GenericJDBCException( "Could not extract row count from CallableStatement", sqle ); + } + if ( batchPosition < 0 ) { + checkNonBatched( expectedRowCount(), result, sql ); + } + else { + checkBatched( expectedRowCount(), result, batchPosition, sql ); + } + } + + @Override + public void validate(boolean callable) throws MappingException { + if ( !callable ) { + throw new MappingException( "Expectation.OutParameter operates exclusively on CallableStatements" ); + } + } + + @Override + public int getNumberOfParametersUsed() { + return 1; + } + + @Override + public int prepare(PreparedStatement statement) throws SQLException, HibernateException { + toCallableStatement( statement ).registerOutParameter( parameterIndex(), Types.NUMERIC ); + return 1; + } + + @Override + public boolean canBeBatched() { + return false; + } + + protected int parameterIndex() { + return 1; + } + + protected int expectedRowCount() { + return 1; + } + } + +} 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 9c8ad687fcdf..0b79d27a22e4 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 @@ -20,7 +20,9 @@ import org.hibernate.engine.jdbc.mutation.internal.PreparedStatementGroupSingleTable; import org.hibernate.engine.jdbc.mutation.spi.Binding; import org.hibernate.engine.jdbc.mutation.spi.BindingGroup; +import org.hibernate.engine.jdbc.spi.JdbcServices; 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.persister.entity.mutation.UpdateValuesAnalysis; @@ -46,6 +48,7 @@ public class DeleteOrUpsertOperation implements SelfExecutingUpdateOperation { private final OptionalTableUpdate optionalTableUpdate; + private final Expectation expectation = new Expectation.RowCount(); public DeleteOrUpsertOperation( EntityMutationTarget mutationTarget, @@ -118,7 +121,8 @@ private void performDelete(JdbcValueBindings jdbcValueBindings, SharedSessionCon try { final PreparedStatement upsertDeleteStatement = statementDetails.resolveStatement(); - session.getJdbcServices().getSqlStatementLogger().logStatement( statementDetails.getSqlString() ); + final JdbcServices jdbcServices = session.getJdbcServices(); + jdbcServices.getSqlStatementLogger().logStatement( statementDetails.getSqlString() ); bindDeleteKeyValues( jdbcValueBindings, @@ -130,6 +134,16 @@ private void performDelete(JdbcValueBindings jdbcValueBindings, SharedSessionCon final int rowCount = session.getJdbcCoordinator().getResultSetReturn() .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() ); + } + catch (SQLException e) { + throw jdbcServices.getSqlExceptionHelper().convert( + e, + "Unable to verify outcome for upsert delete", + statementDetails.getSqlString() + ); + } } finally { statementDetails.releaseStatement( session ); @@ -197,14 +211,24 @@ private void performUpsert(JdbcValueBindings jdbcValueBindings, SharedSessionCon try { final PreparedStatement updateStatement = statementDetails.resolveStatement(); - session.getJdbcServices().getSqlStatementLogger().logStatement( statementDetails.getSqlString() ); - + final JdbcServices jdbcServices = session.getJdbcServices(); + jdbcServices.getSqlStatementLogger().logStatement( statementDetails.getSqlString() ); jdbcValueBindings.beforeStatement( statementDetails ); final int rowCount = session.getJdbcCoordinator().getResultSetReturn() .executeUpdate( updateStatement, statementDetails.getSqlString() ); MODEL_MUTATION_LOGGER.tracef( "`%s` rows upserted into `%s`", rowCount, tableMapping.getTableName() ); + try { + expectation.verifyOutcome( rowCount, updateStatement, -1, statementDetails.getSqlString() ); + } + catch (SQLException e) { + throw jdbcServices.getSqlExceptionHelper().convert( + e, + "Unable to verify outcome for upsert", + statementDetails.getSqlString() + ); + } } finally { statementDetails.releaseStatement( session ); diff --git a/hibernate-core/src/main/java/org/hibernate/sql/model/jdbc/MergeOperation.java b/hibernate-core/src/main/java/org/hibernate/sql/model/jdbc/MergeOperation.java index a583d5aee7c7..19d4a8648556 100644 --- a/hibernate-core/src/main/java/org/hibernate/sql/model/jdbc/MergeOperation.java +++ b/hibernate-core/src/main/java/org/hibernate/sql/model/jdbc/MergeOperation.java @@ -8,7 +8,7 @@ import java.util.List; -import org.hibernate.jdbc.Expectations; +import org.hibernate.jdbc.Expectation; import org.hibernate.sql.exec.spi.JdbcParameterBinder; import org.hibernate.sql.model.MutationTarget; import org.hibernate.sql.model.MutationType; @@ -25,7 +25,7 @@ public MergeOperation( MutationTarget mutationTarget, String sql, List parameterBinders) { - super( tableDetails, mutationTarget, sql, false, Expectations.NONE, parameterBinders ); + super( tableDetails, mutationTarget, sql, false, new Expectation.RowCount(), parameterBinders ); } @Override @@ -33,5 +33,4 @@ public MutationType getMutationType() { return MutationType.UPDATE; } - } diff --git a/hibernate-core/src/main/java/org/hibernate/sql/model/jdbc/OptionalTableUpdateOperation.java b/hibernate-core/src/main/java/org/hibernate/sql/model/jdbc/OptionalTableUpdateOperation.java index d6f7f11b589d..fce14b25094c 100644 --- a/hibernate-core/src/main/java/org/hibernate/sql/model/jdbc/OptionalTableUpdateOperation.java +++ b/hibernate-core/src/main/java/org/hibernate/sql/model/jdbc/OptionalTableUpdateOperation.java @@ -15,6 +15,7 @@ import java.util.Objects; import java.util.Set; +import org.hibernate.StaleStateException; import org.hibernate.engine.jdbc.mutation.JdbcValueBindings; import org.hibernate.engine.jdbc.mutation.ParameterUsage; import org.hibernate.engine.jdbc.mutation.group.PreparedStatementDetails; @@ -27,6 +28,7 @@ import org.hibernate.engine.jdbc.spi.MutationStatementPreparer; import org.hibernate.engine.spi.SessionFactoryImplementor; import org.hibernate.engine.spi.SharedSessionContractImplementor; +import org.hibernate.exception.ConstraintViolationException; import org.hibernate.internal.util.collections.CollectionHelper; import org.hibernate.jdbc.Expectation; import org.hibernate.persister.entity.mutation.EntityMutationTarget; @@ -54,6 +56,7 @@ import org.hibernate.sql.model.internal.TableUpdateCustomSql; import org.hibernate.sql.model.internal.TableUpdateStandard; +import static org.hibernate.exception.ConstraintViolationException.ConstraintKind.UNIQUE; import static org.hibernate.sql.model.ModelMutationLogging.MODEL_MUTATION_LOGGER; /** @@ -159,14 +162,22 @@ public void performMutation( "Upsert update altered no rows - inserting : %s", tableMapping.getTableName() ); - performInsert( jdbcValueBindings, session ); + try { + performInsert( jdbcValueBindings, session ); + } + catch (ConstraintViolationException cve) { + throw cve.getKind() == UNIQUE + // assume it was the primary key constraint which was violated, + // due to a new version of the row existing in the database + ? new StaleStateException( mutationTarget.getRolePath() ) + : cve; + } } } } finally { jdbcValueBindings.afterStatement( tableMapping ); } - } private void performDelete(JdbcValueBindings jdbcValueBindings, SharedSessionContractImplementor session) { 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 index 93d85968e8b2..8d24b44dcc34 100644 --- 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 @@ -8,7 +8,7 @@ import java.util.List; -import org.hibernate.jdbc.Expectations; +import org.hibernate.jdbc.Expectation; import org.hibernate.sql.exec.spi.JdbcParameterBinder; import org.hibernate.sql.model.MutationTarget; import org.hibernate.sql.model.MutationType; @@ -25,7 +25,7 @@ public UpsertOperation( MutationTarget mutationTarget, String sql, List parameterBinders) { - super( tableDetails, mutationTarget, sql, false, Expectations.NONE, parameterBinders ); + super( tableDetails, mutationTarget, sql, false, new Expectation.RowCount(), parameterBinders ); } @Override @@ -33,5 +33,4 @@ public MutationType getMutationType() { return MutationType.UPDATE; } - } 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 07921e8e92d1..d4722101d908 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 @@ -3,45 +3,72 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Version; +import org.hibernate.StaleStateException; 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.Test; -import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; @SessionFactory @DomainModel(annotatedClasses = UpsertVersionedTest.Record.class) public class UpsertVersionedTest { - @Test void test(SessionFactoryScope scope) { - scope.inStatelessTransaction(s-> { - s.upsert(new Record(123L,null,"hello earth")); - s.upsert(new Record(456L,2L,"hello mars")); - }); - scope.inStatelessTransaction(s-> { - 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,0L,"goodbye earth")); - }); - scope.inStatelessTransaction(s-> { - assertEquals("goodbye earth",s.get(Record.class,123L).message); - assertEquals("hello mars",s.get(Record.class,456L).message); - }); - scope.inStatelessTransaction(s-> { - s.upsert(new Record(456L,3L,"goodbye mars")); - }); - scope.inStatelessTransaction(s-> { - assertEquals("goodbye earth",s.get(Record.class,123L).message); - assertEquals("goodbye mars",s.get(Record.class,456L).message); - }); - } - @Entity(name = "Record") - static class Record { - @Id Long id; - @Version Long version; - String message; + + @Test void test(SessionFactoryScope scope) { + scope.getSessionFactory().getSchemaManager().truncateMappedObjects(); + scope.inStatelessTransaction(s-> { + s.upsert(new Record(123L,null,"hello earth")); + s.upsert(new Record(456L,2L,"hello mars")); + }); + scope.inStatelessTransaction(s-> { + 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,0L,"goodbye earth")); + }); + scope.inStatelessTransaction(s-> { + assertEquals( "goodbye earth", s.get( Record.class,123L).message ); + assertEquals( "hello mars", s.get( Record.class,456L).message ); + }); + scope.inStatelessTransaction(s-> { + s.upsert(new Record(456L,3L,"goodbye mars")); + }); + scope.inStatelessTransaction(s-> { + assertEquals( "goodbye earth", s.get( Record.class,123L).message ); + assertEquals( "goodbye mars", s.get( Record.class,456L).message ); + }); + } + + @Test void testStaleUpsert(SessionFactoryScope scope) { + scope.getSessionFactory().getSchemaManager().truncateMappedObjects(); + scope.inStatelessTransaction( s -> { + s.insert(new Record(789L, 1L, "hello world")); + } ); + scope.inStatelessTransaction( s -> { + s.upsert(new Record(789L, 1L, "hello mars")); + } ); + try { + scope.inStatelessTransaction( s -> { + s.upsert(new Record( 789L, 1L, "hello venus")); + } ); + fail(); + } + catch (StaleStateException sse) { + //expected + } + scope.inStatelessTransaction( s-> { + assertEquals( "hello mars", s.get(Record.class,789L).message ); + } ); + } + + @Entity(name = "Record") + static class Record { + @Id Long id; + @Version Long version; + String message; Record(Long id, Long version, String message) { this.id = id;