From 332953c9a474d3b33b6514b36a9fa62fa0f0247d Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 3 Oct 2025 14:51:15 +0200 Subject: [PATCH] Align BatchUpdateException handling among SQLExceptionTranslator variants Closes gh-35547 --- .../SQLExceptionSubclassTranslator.java | 63 +++++++++++-------- .../SQLStateSQLExceptionTranslator.java | 48 +++++++++----- .../SQLExceptionSubclassTranslatorTests.java | 59 +++++++++-------- .../SQLStateSQLExceptionTranslatorTests.java | 41 +++++++++--- 4 files changed, 136 insertions(+), 75 deletions(-) diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLExceptionSubclassTranslator.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLExceptionSubclassTranslator.java index b9febce5056..00d0a6c8a51 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLExceptionSubclassTranslator.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLExceptionSubclassTranslator.java @@ -16,6 +16,7 @@ package org.springframework.jdbc.support; +import java.sql.BatchUpdateException; import java.sql.SQLDataException; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; @@ -51,7 +52,10 @@ import org.springframework.lang.Nullable; *

Falls back to a standard {@link SQLStateSQLExceptionTranslator} if the JDBC * driver does not actually expose JDBC 4 compliant {@code SQLException} subclasses. * - *

This translator serves as the default translator as of 6.0. + *

This translator serves as the default JDBC exception translator as of 6.0. + * As of 6.2.12, it specifically introspects {@link java.sql.BatchUpdateException} + * to look at the underlying exception, analogous to the former default + * {@link SQLErrorCodeSQLExceptionTranslator}. * * @author Thomas Risberg * @author Juergen Hoeller @@ -69,45 +73,50 @@ public class SQLExceptionSubclassTranslator extends AbstractFallbackSQLException @Override @Nullable protected DataAccessException doTranslate(String task, @Nullable String sql, SQLException ex) { - if (ex instanceof SQLTransientException) { - if (ex instanceof SQLTransientConnectionException) { - return new TransientDataAccessResourceException(buildMessage(task, sql, ex), ex); + SQLException sqlEx = ex; + if (sqlEx instanceof BatchUpdateException && sqlEx.getNextException() != null) { + sqlEx = sqlEx.getNextException(); + } + + if (sqlEx instanceof SQLTransientException) { + if (sqlEx instanceof SQLTransientConnectionException) { + return new TransientDataAccessResourceException(buildMessage(task, sql, sqlEx), sqlEx); } - if (ex instanceof SQLTransactionRollbackException) { - if (SQLStateSQLExceptionTranslator.indicatesCannotAcquireLock(ex.getSQLState())) { - return new CannotAcquireLockException(buildMessage(task, sql, ex), ex); + if (sqlEx instanceof SQLTransactionRollbackException) { + if (SQLStateSQLExceptionTranslator.indicatesCannotAcquireLock(sqlEx.getSQLState())) { + return new CannotAcquireLockException(buildMessage(task, sql, sqlEx), sqlEx); } - return new PessimisticLockingFailureException(buildMessage(task, sql, ex), ex); + return new PessimisticLockingFailureException(buildMessage(task, sql, sqlEx), sqlEx); } - if (ex instanceof SQLTimeoutException) { - return new QueryTimeoutException(buildMessage(task, sql, ex), ex); + if (sqlEx instanceof SQLTimeoutException) { + return new QueryTimeoutException(buildMessage(task, sql, sqlEx), sqlEx); } } - else if (ex instanceof SQLNonTransientException) { - if (ex instanceof SQLNonTransientConnectionException) { - return new DataAccessResourceFailureException(buildMessage(task, sql, ex), ex); + else if (sqlEx instanceof SQLNonTransientException) { + if (sqlEx instanceof SQLNonTransientConnectionException) { + return new DataAccessResourceFailureException(buildMessage(task, sql, sqlEx), sqlEx); } - if (ex instanceof SQLDataException) { - return new DataIntegrityViolationException(buildMessage(task, sql, ex), ex); + if (sqlEx instanceof SQLDataException) { + return new DataIntegrityViolationException(buildMessage(task, sql, sqlEx), sqlEx); } - if (ex instanceof SQLIntegrityConstraintViolationException) { - if (SQLStateSQLExceptionTranslator.indicatesDuplicateKey(ex.getSQLState(), ex.getErrorCode())) { - return new DuplicateKeyException(buildMessage(task, sql, ex), ex); + if (sqlEx instanceof SQLIntegrityConstraintViolationException) { + if (SQLStateSQLExceptionTranslator.indicatesDuplicateKey(sqlEx.getSQLState(), sqlEx.getErrorCode())) { + return new DuplicateKeyException(buildMessage(task, sql, sqlEx), sqlEx); } - return new DataIntegrityViolationException(buildMessage(task, sql, ex), ex); + return new DataIntegrityViolationException(buildMessage(task, sql, sqlEx), sqlEx); } - if (ex instanceof SQLInvalidAuthorizationSpecException) { - return new PermissionDeniedDataAccessException(buildMessage(task, sql, ex), ex); + if (sqlEx instanceof SQLInvalidAuthorizationSpecException) { + return new PermissionDeniedDataAccessException(buildMessage(task, sql, sqlEx), sqlEx); } - if (ex instanceof SQLSyntaxErrorException) { - return new BadSqlGrammarException(task, (sql != null ? sql : ""), ex); + if (sqlEx instanceof SQLSyntaxErrorException) { + return new BadSqlGrammarException(task, (sql != null ? sql : ""), sqlEx); } - if (ex instanceof SQLFeatureNotSupportedException) { - return new InvalidDataAccessApiUsageException(buildMessage(task, sql, ex), ex); + if (sqlEx instanceof SQLFeatureNotSupportedException) { + return new InvalidDataAccessApiUsageException(buildMessage(task, sql, sqlEx), sqlEx); } } - else if (ex instanceof SQLRecoverableException) { - return new RecoverableDataAccessException(buildMessage(task, sql, ex), ex); + else if (sqlEx instanceof SQLRecoverableException) { + return new RecoverableDataAccessException(buildMessage(task, sql, sqlEx), sqlEx); } // Fallback to Spring's own SQL state translation... diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslator.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslator.java index daecc4f2090..a7d18f8a9f0 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslator.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslator.java @@ -16,6 +16,7 @@ package org.springframework.jdbc.support; +import java.sql.BatchUpdateException; import java.sql.SQLException; import java.util.Set; @@ -41,7 +42,9 @@ import org.springframework.lang.Nullable; * *

This translator is commonly used as a {@link #setFallbackTranslator fallback} * behind a primary translator such as {@link SQLErrorCodeSQLExceptionTranslator} or - * {@link SQLExceptionSubclassTranslator}. + * {@link SQLExceptionSubclassTranslator}. As of 6.2.12, it specifically introspects + * {@link java.sql.BatchUpdateException} to look at the underlying exception + * (for alignment when used behind a {@link SQLExceptionSubclassTranslator}). * * @author Rod Johnson * @author Juergen Hoeller @@ -103,43 +106,60 @@ public class SQLStateSQLExceptionTranslator extends AbstractFallbackSQLException @Override @Nullable protected DataAccessException doTranslate(String task, @Nullable String sql, SQLException ex) { - // First, the getSQLState check... - String sqlState = getSqlState(ex); + SQLException sqlEx = ex; + String sqlState; + if (sqlEx instanceof BatchUpdateException) { + // Unwrap BatchUpdateException to expose contained exception + // with potentially more specific SQL state. + if (sqlEx.getNextException() != null) { + SQLException nestedSqlEx = sqlEx.getNextException(); + if (nestedSqlEx.getSQLState() != null) { + sqlEx = nestedSqlEx; + } + } + sqlState = sqlEx.getSQLState(); + } + else { + // Expose top-level exception but potentially use nested SQL state. + sqlState = getSqlState(sqlEx); + } + + // The actual SQL state check... if (sqlState != null && sqlState.length() >= 2) { String classCode = sqlState.substring(0, 2); if (logger.isDebugEnabled()) { logger.debug("Extracted SQL state class '" + classCode + "' from value '" + sqlState + "'"); } if (BAD_SQL_GRAMMAR_CODES.contains(classCode)) { - return new BadSqlGrammarException(task, (sql != null ? sql : ""), ex); + return new BadSqlGrammarException(task, (sql != null ? sql : ""), sqlEx); } else if (DATA_INTEGRITY_VIOLATION_CODES.contains(classCode)) { - if (indicatesDuplicateKey(sqlState, ex.getErrorCode())) { - return new DuplicateKeyException(buildMessage(task, sql, ex), ex); + if (indicatesDuplicateKey(sqlState, sqlEx.getErrorCode())) { + return new DuplicateKeyException(buildMessage(task, sql, sqlEx), sqlEx); } - return new DataIntegrityViolationException(buildMessage(task, sql, ex), ex); + return new DataIntegrityViolationException(buildMessage(task, sql, sqlEx), sqlEx); } else if (PESSIMISTIC_LOCKING_FAILURE_CODES.contains(classCode)) { if (indicatesCannotAcquireLock(sqlState)) { - return new CannotAcquireLockException(buildMessage(task, sql, ex), ex); + return new CannotAcquireLockException(buildMessage(task, sql, sqlEx), sqlEx); } - return new PessimisticLockingFailureException(buildMessage(task, sql, ex), ex); + return new PessimisticLockingFailureException(buildMessage(task, sql, sqlEx), sqlEx); } else if (DATA_ACCESS_RESOURCE_FAILURE_CODES.contains(classCode)) { if (indicatesQueryTimeout(sqlState)) { - return new QueryTimeoutException(buildMessage(task, sql, ex), ex); + return new QueryTimeoutException(buildMessage(task, sql, sqlEx), sqlEx); } - return new DataAccessResourceFailureException(buildMessage(task, sql, ex), ex); + return new DataAccessResourceFailureException(buildMessage(task, sql, sqlEx), sqlEx); } else if (TRANSIENT_DATA_ACCESS_RESOURCE_CODES.contains(classCode)) { - return new TransientDataAccessResourceException(buildMessage(task, sql, ex), ex); + return new TransientDataAccessResourceException(buildMessage(task, sql, sqlEx), sqlEx); } } // For MySQL: exception class name indicating a timeout? // (since MySQL doesn't throw the JDBC 4 SQLTimeoutException) - if (ex.getClass().getName().contains("Timeout")) { - return new QueryTimeoutException(buildMessage(task, sql, ex), ex); + if (sqlEx.getClass().getName().contains("Timeout")) { + return new QueryTimeoutException(buildMessage(task, sql, sqlEx), sqlEx); } // Couldn't resolve anything proper - resort to UncategorizedSQLException. diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/support/SQLExceptionSubclassTranslatorTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/support/SQLExceptionSubclassTranslatorTests.java index 43a4928b4d5..95303d60743 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/support/SQLExceptionSubclassTranslatorTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/support/SQLExceptionSubclassTranslatorTests.java @@ -43,7 +43,7 @@ import org.springframework.dao.RecoverableDataAccessException; import org.springframework.dao.TransientDataAccessResourceException; import org.springframework.jdbc.BadSqlGrammarException; -import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.jdbc.support.SQLStateSQLExceptionTranslatorTests.buildBatchUpdateException; /** * @author Thomas Risberg @@ -51,43 +51,50 @@ import static org.assertj.core.api.Assertions.assertThat; */ class SQLExceptionSubclassTranslatorTests { + private final SQLExceptionTranslator translator = new SQLExceptionSubclassTranslator(); + + @Test void exceptionClassTranslation() { - doTest(new SQLDataException("", "", 0), DataIntegrityViolationException.class); - doTest(new SQLFeatureNotSupportedException("", "", 0), InvalidDataAccessApiUsageException.class); - doTest(new SQLIntegrityConstraintViolationException("", "", 0), DataIntegrityViolationException.class); - doTest(new SQLIntegrityConstraintViolationException("", "23505", 0), DuplicateKeyException.class); - doTest(new SQLIntegrityConstraintViolationException("", "23000", 1), DuplicateKeyException.class); - doTest(new SQLIntegrityConstraintViolationException("", "23000", 1062), DuplicateKeyException.class); - doTest(new SQLIntegrityConstraintViolationException("", "23000", 2601), DuplicateKeyException.class); - doTest(new SQLIntegrityConstraintViolationException("", "23000", 2627), DuplicateKeyException.class); - doTest(new SQLInvalidAuthorizationSpecException("", "", 0), PermissionDeniedDataAccessException.class); - doTest(new SQLNonTransientConnectionException("", "", 0), DataAccessResourceFailureException.class); - doTest(new SQLRecoverableException("", "", 0), RecoverableDataAccessException.class); - doTest(new SQLSyntaxErrorException("", "", 0), BadSqlGrammarException.class); - doTest(new SQLTimeoutException("", "", 0), QueryTimeoutException.class); - doTest(new SQLTransactionRollbackException("", "", 0), PessimisticLockingFailureException.class); - doTest(new SQLTransactionRollbackException("", "40001", 0), CannotAcquireLockException.class); - doTest(new SQLTransientConnectionException("", "", 0), TransientDataAccessResourceException.class); + assertTranslation(new SQLDataException("", "", 0), DataIntegrityViolationException.class); + assertTranslation(new SQLFeatureNotSupportedException("", "", 0), InvalidDataAccessApiUsageException.class); + assertTranslation(new SQLIntegrityConstraintViolationException("", "", 0), DataIntegrityViolationException.class); + assertTranslation(new SQLIntegrityConstraintViolationException("", "23505", 0), DuplicateKeyException.class); + assertTranslation(new SQLIntegrityConstraintViolationException("", "23000", 1), DuplicateKeyException.class); + assertTranslation(new SQLIntegrityConstraintViolationException("", "23000", 1062), DuplicateKeyException.class); + assertTranslation(new SQLIntegrityConstraintViolationException("", "23000", 2601), DuplicateKeyException.class); + assertTranslation(new SQLIntegrityConstraintViolationException("", "23000", 2627), DuplicateKeyException.class); + assertTranslation(new SQLInvalidAuthorizationSpecException("", "", 0), PermissionDeniedDataAccessException.class); + assertTranslation(new SQLNonTransientConnectionException("", "", 0), DataAccessResourceFailureException.class); + assertTranslation(new SQLRecoverableException("", "", 0), RecoverableDataAccessException.class); + assertTranslation(new SQLSyntaxErrorException("", "", 0), BadSqlGrammarException.class); + assertTranslation(new SQLTimeoutException("", "", 0), QueryTimeoutException.class); + assertTranslation(new SQLTransactionRollbackException("", "", 0), PessimisticLockingFailureException.class); + assertTranslation(new SQLTransactionRollbackException("", "40001", 0), CannotAcquireLockException.class); + assertTranslation(new SQLTransientConnectionException("", "", 0), TransientDataAccessResourceException.class); + } + + @Test + void batchExceptionTranslation() { + assertTranslation(buildBatchUpdateException("JZ", new SQLIntegrityConstraintViolationException("", "23505", 0)), + DuplicateKeyException.class); + assertTranslation(buildBatchUpdateException(null, new SQLIntegrityConstraintViolationException("", "23505", 0)), + DuplicateKeyException.class); } @Test void fallbackStateTranslation() { // Test fallback. We assume that no database will ever return this error code, // but 07xxx will be bad grammar picked up by the fallback SQLState translator - doTest(new SQLException("", "07xxx", 666666666), BadSqlGrammarException.class); + assertTranslation(new SQLException("", "07xxx", 666666666), BadSqlGrammarException.class); // and 08xxx will be data resource failure (non-transient) picked up by the fallback SQLState translator - doTest(new SQLException("", "08xxx", 666666666), DataAccessResourceFailureException.class); + assertTranslation(new SQLException("", "08xxx", 666666666), DataAccessResourceFailureException.class); } - private void doTest(SQLException ex, Class dataAccessExceptionType) { - SQLExceptionTranslator translator = new SQLExceptionSubclassTranslator(); - DataAccessException dax = translator.translate("task", "SQL", ex); - - assertThat(dax).as("Specific translation must not result in null").isNotNull(); - assertThat(dax).as("Wrong DataAccessException type returned").isExactlyInstanceOf(dataAccessExceptionType); - assertThat(dax.getCause()).as("The exact same original SQLException must be preserved").isSameAs(ex); + private void assertTranslation(SQLException ex, Class dataAccessExceptionType) { + DataAccessException dae = translator.translate("task", "SQL", ex); + SQLStateSQLExceptionTranslatorTests.assertTranslation(dae, ex, dataAccessExceptionType); } } diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslatorTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslatorTests.java index 263eb8f829d..fded7c4bedc 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslatorTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslatorTests.java @@ -16,6 +16,7 @@ package org.springframework.jdbc.support; +import java.sql.BatchUpdateException; import java.sql.SQLException; import org.junit.jupiter.api.Test; @@ -45,6 +46,7 @@ class SQLStateSQLExceptionTranslatorTests { private final SQLExceptionTranslator translator = new SQLStateSQLExceptionTranslator(); + @Test void translateNullException() { assertThatIllegalArgumentException().isThrownBy(() -> translator.translate("", "", null)); @@ -125,6 +127,16 @@ class SQLStateSQLExceptionTranslatorTests { assertTranslation("57014", QueryTimeoutException.class); } + @Test + void translateWithinQualifiedBatch() { + assertTranslation(buildBatchUpdateException("JZ", new SQLException("", "23505", 0)), DuplicateKeyException.class); + } + + @Test + void translateWithinUnqualifiedBatch() { + assertTranslation(buildBatchUpdateException(null, new SQLException("", "23505", 0)), DuplicateKeyException.class); + } + @Test void translateUncategorized() { assertTranslation("00000000", null); @@ -142,28 +154,41 @@ class SQLStateSQLExceptionTranslatorTests { */ @Test void malformedSqlStateCodes() { - assertTranslation(null, null); + assertTranslation((String) null, null); assertTranslation("", null); assertTranslation("I", null); } private void assertTranslation(@Nullable String sqlState, @Nullable Class dataAccessExceptionType) { - assertTranslation(sqlState, 0, dataAccessExceptionType); + assertTranslation(new SQLException("reason", sqlState, 0), dataAccessExceptionType); } private void assertTranslation(@Nullable String sqlState, int errorCode, @Nullable Class dataAccessExceptionType) { - SQLException ex = new SQLException("reason", sqlState, errorCode); - DataAccessException dax = translator.translate("task", "SQL", ex); + assertTranslation(new SQLException("reason", sqlState, errorCode), dataAccessExceptionType); + } + + private void assertTranslation(SQLException ex, @Nullable Class dataAccessExceptionType) { + DataAccessException dae = translator.translate("task", "SQL", ex); if (dataAccessExceptionType == null) { - assertThat(dax).as("Expected translation to null").isNull(); + assertThat(dae).as("Expected translation to null").isNull(); return; } + assertTranslation(dae, ex, dataAccessExceptionType); + } + + static void assertTranslation(DataAccessException dae, SQLException ex, Class dataAccessExceptionType) { + assertThat(dae).as("Specific translation must not result in null").isNotNull(); + assertThat(dae).as("Wrong DataAccessException type returned").isExactlyInstanceOf(dataAccessExceptionType); + assertThat(dae.getCause()).as("The exact same original SQLException must be preserved").isSameAs( + ex instanceof BatchUpdateException bue ? bue.getNextException() : ex); + } - assertThat(dax).as("Specific translation must not result in null").isNotNull(); - assertThat(dax).as("Wrong DataAccessException type returned").isExactlyInstanceOf(dataAccessExceptionType); - assertThat(dax.getCause()).as("The exact same original SQLException must be preserved").isSameAs(ex); + static BatchUpdateException buildBatchUpdateException(@Nullable String sqlState, SQLException next) { + BatchUpdateException ex = new BatchUpdateException("", sqlState, null); + ex.setNextException(next); + return ex; } }