diff --git a/hibernate-core/src/main/java/org/hibernate/StaleStateException.java b/hibernate-core/src/main/java/org/hibernate/StaleStateException.java index f2fe3e688def..0643988ff07c 100644 --- a/hibernate-core/src/main/java/org/hibernate/StaleStateException.java +++ b/hibernate-core/src/main/java/org/hibernate/StaleStateException.java @@ -26,4 +26,15 @@ public class StaleStateException extends HibernateException { public StaleStateException(String message) { super( message ); } + + /** + * Constructs a {@code StaleStateException} using the supplied message + * and cause. + * + * @param message The message explaining the exception condition + * @param cause An exception to wrap + */ + public StaleStateException(String message, Exception cause) { + super( message, cause ); + } } diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/SqlAstTranslatorWithMerge.java b/hibernate-core/src/main/java/org/hibernate/dialect/SqlAstTranslatorWithMerge.java index 810d2bb2f211..80c007443109 100644 --- a/hibernate-core/src/main/java/org/hibernate/dialect/SqlAstTranslatorWithMerge.java +++ b/hibernate-core/src/main/java/org/hibernate/dialect/SqlAstTranslatorWithMerge.java @@ -11,6 +11,7 @@ import org.hibernate.engine.spi.SessionFactoryImplementor; import org.hibernate.internal.util.StringHelper; import org.hibernate.sql.ast.spi.AbstractSqlAstTranslator; +import org.hibernate.jdbc.Expectation; import org.hibernate.sql.ast.tree.Statement; import org.hibernate.sql.exec.spi.JdbcOperation; import org.hibernate.sql.model.ast.ColumnValueBinding; @@ -46,6 +47,10 @@ public MergeOperation createMergeOperation(OptionalTableUpdate optionalTableUpda optionalTableUpdate.getMutatingTable().getTableMapping(), optionalTableUpdate.getMutationTarget(), getSql(), + // Without value bindings, the upsert may have an update count of 0 + optionalTableUpdate.getValueBindings().isEmpty() + ? new Expectation.OptionalRowCount() + : new Expectation.RowCount(), getParameterBinders() ); } @@ -228,16 +233,18 @@ protected void renderMergeUpdate(OptionalTableUpdate optionalTableUpdate) { final List valueBindings = optionalTableUpdate.getValueBindings(); final List optimisticLockBindings = optionalTableUpdate.getOptimisticLockBindings(); - renderWhenMatched( optimisticLockBindings ); - appendSql( " then update set " ); - for ( int i = 0; i < valueBindings.size(); i++ ) { - final ColumnValueBinding binding = valueBindings.get( i ); - if ( i > 0 ) { - appendSql( ", " ); + if ( !valueBindings.isEmpty() ) { + renderWhenMatched( optimisticLockBindings ); + appendSql( " 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, null ); + appendSql( "=" ); + binding.getColumnReference().appendColumnForWrite( this, "s" ); } - binding.getColumnReference().appendColumnForWrite( this, null ); - appendSql( "=" ); - binding.getColumnReference().appendColumnForWrite( this, "s" ); } } diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/SqlAstTranslatorWithUpsert.java b/hibernate-core/src/main/java/org/hibernate/dialect/SqlAstTranslatorWithUpsert.java index 0ec0f082e0ed..001b326b6922 100644 --- a/hibernate-core/src/main/java/org/hibernate/dialect/SqlAstTranslatorWithUpsert.java +++ b/hibernate-core/src/main/java/org/hibernate/dialect/SqlAstTranslatorWithUpsert.java @@ -9,6 +9,7 @@ import java.util.List; import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.hibernate.jdbc.Expectation; import org.hibernate.persister.entity.mutation.EntityTableMapping; import org.hibernate.sql.ast.spi.AbstractSqlAstTranslator; import org.hibernate.sql.ast.tree.Statement; @@ -39,6 +40,10 @@ public MutationOperation createMergeOperation(OptionalTableUpdate optionalTableU optionalTableUpdate.getMutatingTable().getTableMapping(), optionalTableUpdate.getMutationTarget(), getSql(), + // Without value bindings, the upsert may have an update count of 0 + optionalTableUpdate.getValueBindings().isEmpty() + ? new Expectation.OptionalRowCount() + : new Expectation.RowCount(), getParameterBinders() ); @@ -193,17 +198,19 @@ protected void renderMergeUpdate(OptionalTableUpdate optionalTableUpdate) { final List valueBindings = optionalTableUpdate.getValueBindings(); final List optimisticLockBindings = optionalTableUpdate.getOptimisticLockBindings(); - appendSql( " when matched then update set " ); - for ( int i = 0; i < valueBindings.size(); i++ ) { - final ColumnValueBinding binding = valueBindings.get( i ); - if ( i > 0 ) { - appendSql( ", " ); + if ( !valueBindings.isEmpty() ) { + 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" ); } - binding.getColumnReference().appendColumnForWrite( this, "t" ); - appendSql( "=" ); - binding.getColumnReference().appendColumnForWrite( this, "s" ); + renderMatchedWhere( optimisticLockBindings ); } - renderMatchedWhere( optimisticLockBindings ); } private void renderMatchedWhere(List optimisticLockBindings) { 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..f8c3cf965575 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,268 @@ -/* - * 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. - *
    - *
  • The argument to {@code rowCount} is usually the number of - * table rows affected by execution of the SQL statement via - * {@code executeUpdate()}. However, in the case where - * {@code executeBatch()} is used to execute a batch update, - * it might be {@link PreparedStatement#EXECUTE_FAILED} or - * {@link PreparedStatement#SUCCESS_NO_INFO}. - *
  • The argument to {@code batchPosition} is negative unless - * {@code executeBatch()} is used to execute a batch update, - * in which case it is the position within the batch of the - * row count being verified. - *
- * - * @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. + *
    + *
  • The argument to {@code rowCount} is usually the number of + * table rows affected by execution of the SQL statement via + * {@code executeUpdate()}. However, in the case where + * {@code executeBatch()} is used to execute a batch update, + * it might be {@link PreparedStatement#EXECUTE_FAILED} or + * {@link PreparedStatement#SUCCESS_NO_INFO}. + *
  • The argument to {@code batchPosition} is negative unless + * {@code executeBatch()} is used to execute a batch update, + * in which case it is the position within the batch of the + * row count being verified. + *
+ * + * @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; + } + } + + /** + * 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, but is also allowed to be 0. + * For example, the expected row count for an {@code UPSERT} statement is 0 or 1. + */ + class OptionalRowCount implements Expectation { + @Override + public final void verifyOutcome(int rowCount, PreparedStatement statement, int batchPosition, String sql) { + if ( rowCount != 0 ) { + 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; + } + } + +} diff --git a/hibernate-core/src/main/java/org/hibernate/persister/entity/AbstractEntityPersister.java b/hibernate-core/src/main/java/org/hibernate/persister/entity/AbstractEntityPersister.java index 6a1efd8764bb..cf4941437ce1 100644 --- a/hibernate-core/src/main/java/org/hibernate/persister/entity/AbstractEntityPersister.java +++ b/hibernate-core/src/main/java/org/hibernate/persister/entity/AbstractEntityPersister.java @@ -2982,7 +2982,7 @@ public GeneratedValuesMutationDelegate getUpdateDelegate() { return updateDelegate; } - protected EntityTableMapping[] getTableMappings() { + public EntityTableMapping[] getTableMappings() { return tableMappings; } @@ -3833,15 +3833,7 @@ protected UpdateCoordinator buildUpdateCoordinator() { } protected UpdateCoordinator buildMergeCoordinator() { - // we only have updates to issue for entities with one or more singular attributes - for ( int i = 0; i < attributeMappings.size(); i++ ) { - final AttributeMapping attributeMapping = attributeMappings.get( i ); - if ( attributeMapping instanceof SingularAttributeMapping ) { - return new MergeCoordinator( this, factory ); - } - } - // otherwise, nothing to update - return new UpdateCoordinatorNoOp( this ); + return new MergeCoordinator( this, factory ); } protected DeleteCoordinator buildDeleteCoordinator() { diff --git a/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/MergeCoordinator.java b/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/MergeCoordinator.java index 7db29d7385c6..ad656bfdb5c9 100644 --- a/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/MergeCoordinator.java +++ b/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/MergeCoordinator.java @@ -7,6 +7,7 @@ package org.hibernate.persister.entity.mutation; import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.hibernate.engine.spi.SharedSessionContractImplementor; import org.hibernate.persister.entity.AbstractEntityPersister; import org.hibernate.sql.model.MutationOperation; import org.hibernate.sql.model.ast.builder.AbstractTableUpdateBuilder; @@ -28,4 +29,49 @@ protected AbstractTableUpdateBuilder newTableUp return new TableMergeBuilder<>( entityPersister(), tableMapping, factory() ); } + @Override + protected UpdateValuesAnalysisImpl analyzeUpdateValues( + Object entity, + Object[] values, + Object oldVersion, + Object[] oldValues, + int[] dirtyAttributeIndexes, + InclusionChecker inclusionChecker, + InclusionChecker lockingChecker, + InclusionChecker dirtinessChecker, + Object rowId, + boolean forceDynamicUpdate, + SharedSessionContractImplementor session) { + final UpdateValuesAnalysisImpl updateValuesAnalysis = super.analyzeUpdateValues( + entity, + values, + oldVersion, + oldValues, + dirtyAttributeIndexes, + inclusionChecker, + lockingChecker, + dirtinessChecker, + rowId, + forceDynamicUpdate, + session + ); + if ( oldValues == null ) { + final TableSet tablesNeedingUpdate = updateValuesAnalysis.getTablesNeedingUpdate(); + final TableSet tablesWithNonNullValues = updateValuesAnalysis.getTablesWithNonNullValues(); + final TableSet tablesWithPreviousNonNullValues = updateValuesAnalysis.getTablesWithPreviousNonNullValues(); + for ( EntityTableMapping tableMapping : entityPersister().getTableMappings() ) { + // Need to upsert into all non-optional table mappings + if ( !tableMapping.isOptional() ) { + // If the table was previously not needing an update, remove it from tablesWithPreviousNonNullValues + // to avoid triggering a delete-statement for this operation + if ( !tablesNeedingUpdate.contains( tableMapping ) ) { + tablesWithPreviousNonNullValues.remove( tableMapping ); + } + tablesNeedingUpdate.add( tableMapping ); + tablesWithNonNullValues.add( tableMapping ); + } + } + } + return updateValuesAnalysis; + } } diff --git a/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/TableSet.java b/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/TableSet.java index d248a3ba7a64..e8b0103f9fe2 100644 --- a/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/TableSet.java +++ b/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/TableSet.java @@ -36,6 +36,13 @@ public void add(final TableMapping tableMapping) { bits.set( tableMapping.getRelativePosition() ); } + public void remove(final TableMapping tableMapping) { + if ( bits != null ) { + assert addForChecks( tableMapping ); + bits.set( tableMapping.getRelativePosition(), false ); + } + } + public boolean isEmpty() { return bits == null; } diff --git a/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/UpdateCoordinatorStandard.java b/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/UpdateCoordinatorStandard.java index 5df81f6a51c5..1faedad5d50f 100644 --- a/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/UpdateCoordinatorStandard.java +++ b/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/UpdateCoordinatorStandard.java @@ -609,7 +609,7 @@ && entityPersister().getVersionMapping().getVersionAttribute().isUpdateable() ) } } - private UpdateValuesAnalysisImpl analyzeUpdateValues( + protected UpdateValuesAnalysisImpl analyzeUpdateValues( Object entity, Object[] values, Object oldVersion, diff --git a/hibernate-core/src/main/java/org/hibernate/sql/model/ast/builder/TableMergeBuilder.java b/hibernate-core/src/main/java/org/hibernate/sql/model/ast/builder/TableMergeBuilder.java index b5d147f68a2e..e80be7797b59 100644 --- a/hibernate-core/src/main/java/org/hibernate/sql/model/ast/builder/TableMergeBuilder.java +++ b/hibernate-core/src/main/java/org/hibernate/sql/model/ast/builder/TableMergeBuilder.java @@ -14,7 +14,6 @@ import org.hibernate.sql.model.ast.MutatingTableReference; import org.hibernate.sql.model.ast.RestrictedTableMutation; import org.hibernate.sql.model.internal.OptionalTableUpdate; -import org.hibernate.sql.model.internal.TableUpdateNoSet; import java.util.List; @@ -41,9 +40,6 @@ public TableMergeBuilder( @Override public RestrictedTableMutation buildMutation() { final List valueBindings = combine( getValueBindings(), getKeyBindings(), getLobValueBindings() ); - if ( valueBindings.isEmpty() ) { - return (RestrictedTableMutation) new TableUpdateNoSet( getMutatingTable(), getMutationTarget() ); - } // TODO: add getMergeDetails() // if ( getMutatingTable().getTableMapping().getUpdateDetails().getCustomSql() != null ) { 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..ca762e105eb3 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,16 @@ public MergeOperation( MutationTarget mutationTarget, String sql, List parameterBinders) { - super( tableDetails, mutationTarget, sql, false, Expectations.NONE, parameterBinders ); + this( tableDetails, mutationTarget, sql, new Expectation.RowCount(), parameterBinders ); + } + + public MergeOperation( + TableMapping tableDetails, + MutationTarget mutationTarget, + String sql, + Expectation expectation, + List parameterBinders) { + super( tableDetails, mutationTarget, sql, false, expectation, parameterBinders ); } @Override 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..c3e1c93dd546 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,28 @@ public void performMutation( "Upsert update altered no rows - inserting : %s", tableMapping.getTableName() ); - performInsert( jdbcValueBindings, session ); + try { + performInsert( jdbcValueBindings, session ); + } + catch (ConstraintViolationException cve) { + if ( cve.getKind() == UNIQUE ) { + // Ignore primary key violation if the insert is composed of just the primary key + if ( !valueBindings.isEmpty() ) { + // assume it was the primary key constraint which was violated, + // due to a new version of the row existing in the database + throw new StaleStateException( mutationTarget.getRolePath(), cve ); + } + } + else { + throw 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..af19211bfc10 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,16 @@ public UpsertOperation( MutationTarget mutationTarget, String sql, List parameterBinders) { - super( tableDetails, mutationTarget, sql, false, Expectations.NONE, parameterBinders ); + this( tableDetails, mutationTarget, sql, new Expectation.RowCount(), parameterBinders ); + } + + public UpsertOperation( + TableMapping tableDetails, + MutationTarget mutationTarget, + String sql, + Expectation expectation, + List parameterBinders) { + super( tableDetails, mutationTarget, sql, false, expectation, parameterBinders ); } @Override 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 86dbbbd91376..2e262db272b9 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 @@ -2,15 +2,23 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; 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.assertNotNull; @SessionFactory -@DomainModel(annotatedClasses = UpsertTest.Record.class) +@DomainModel(annotatedClasses = { + UpsertTest.Record.class, + UpsertTest.IdOnly.class, + UpsertTest.IdOnlyIntermediate.class, + UpsertTest.IdOnlySubtype.class +}) public class UpsertTest { @Test void test(SessionFactoryScope scope) { scope.inStatelessTransaction(s-> { @@ -29,7 +37,44 @@ public class UpsertTest { assertEquals("hello mars",s.get(Record.class,456L).message); }); } - @Entity + @Test void testIdOnly(SessionFactoryScope scope) { + scope.inTransaction(s-> { + s.createMutationQuery( "delete from IdOnly" ).executeUpdate(); + }); + scope.inStatelessTransaction(s-> { + s.upsert(new IdOnly(123L)); + }); + scope.inStatelessTransaction(s-> { + assertNotNull(s.get( IdOnly.class,123L)); + }); + scope.inStatelessTransaction(s-> { + s.upsert(new IdOnly(123L)); + }); + scope.inStatelessTransaction(s-> { + assertNotNull(s.get( IdOnly.class,123L)); + }); + } + + @Test void testIdOnlySubtype(SessionFactoryScope scope) { + scope.inTransaction(s-> { + s.createMutationQuery( "delete from IdOnly" ).executeUpdate(); + }); + + scope.inStatelessTransaction(s-> { + s.upsert(new IdOnlySubtype(123L)); + }); + scope.inStatelessTransaction(s-> { + assertNotNull(s.get( IdOnlySubtype.class,123L)); + }); + scope.inStatelessTransaction(s-> { + s.upsert(new IdOnlySubtype(123L)); + }); + scope.inStatelessTransaction(s-> { + assertNotNull(s.get( IdOnlySubtype.class,123L)); + }); + } + + @Entity static class Record { @Id Long id; String message; @@ -42,4 +87,39 @@ static class Record { Record() { } } + + @Entity(name = "IdOnly") + @Inheritance(strategy = InheritanceType.JOINED) + static class IdOnly { + @Id Long id; + + IdOnly(Long id) { + this.id = id; + } + + IdOnly() { + } + } + + @Entity(name = "IdOnlyIntermediate") + static class IdOnlyIntermediate extends IdOnly { + IdOnlyIntermediate(Long id) { + super( id ); + } + + IdOnlyIntermediate() { + } + } + + @Entity(name = "IdOnlySubtype") + static class IdOnlySubtype extends IdOnlyIntermediate { + String name; + + IdOnlySubtype(Long id) { + super( id ); + } + + IdOnlySubtype() { + } + } }