From fe154b9a8ca4304fb46240878003c493bbf6366c Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Mon, 10 Aug 2026 13:31:10 +0200 Subject: [PATCH] features/sql: report integrity constraint violations as their own exception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mutation statement rejected by the database in SQLSTATE class 23 — a CHECK or foreign-key constraint, a unique index, or a trigger raising one — is caused by the data the client sent, not by a bug or an infrastructure problem. Every SQLException was wrapped in an IllegalStateException, so callers could not tell the two apart, report the rejection as a client error, or log it without a stack trace. - new FeatureMutationConstraintException, carrying the SQLSTATE - JdbcSqlSession.mutationFailed() picks the exception type from the SQLSTATE and replaces the four wrap sites; anything else stays an IllegalStateException and keeps its stack trace - the SQLSTATE is found by iterating the SQLException itself, which walks the next-exception chain as well as the causal one: executeBatch() reports a BatchUpdateException whose actual error is a next-exception, not a cause --- .../features/sql/infra/db/JdbcSqlSession.java | 46 +++++++++++++++---- .../FeatureMutationConstraintException.java | 30 ++++++++++++ 2 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureMutationConstraintException.java diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/JdbcSqlSession.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/JdbcSqlSession.java index 3512de43b..631baca78 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/JdbcSqlSession.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/JdbcSqlSession.java @@ -8,6 +8,7 @@ package de.ii.xtraplatform.features.sql.infra.db; import de.ii.xtraplatform.base.domain.LogContext.MARKER; +import de.ii.xtraplatform.features.domain.FeatureMutationConstraintException; import de.ii.xtraplatform.features.domain.FeatureMutationHookException; import de.ii.xtraplatform.features.sql.domain.SqlSession; import java.sql.Connection; @@ -83,8 +84,7 @@ public String run( } batchStmt.addBatch(sql); } catch (SQLException e) { - throw new IllegalStateException( - "Mutation statement failed: " + e.getMessage() + " — statement: " + sql, e); + throw mutationFailed("Mutation statement failed: ", sql, e); } batchedSql.add(sql); batchedConsumers.add(consumer); @@ -120,8 +120,7 @@ public String run( firstGeneratedId = returnedId; } } catch (SQLException e) { - throw new IllegalStateException( - "Mutation statement failed: " + e.getMessage() + " — statement: " + sql, e); + throw mutationFailed("Mutation statement failed: ", sql, e); } } @@ -162,8 +161,7 @@ public List runReturning(String sql) { } return ids; } catch (SQLException e) { - throw new IllegalStateException( - "Mutation statement failed: " + e.getMessage() + " — statement: " + sql, e); + throw mutationFailed("Mutation statement failed: ", sql, e); } } @@ -229,6 +227,38 @@ public List drainWarnings() { return drained; } + /** + * Wrap a failed mutation statement. A rejection in SQLSTATE class 23 (integrity constraint + * violation — CHECK, foreign key, unique index, or a trigger raising one) is caused by the data + * the client sent, so it gets its own exception type that callers can report and log quietly; + * anything else stays an IllegalStateException and keeps its stack trace. + */ + private static RuntimeException mutationFailed(String prefix, String sql, SQLException e) { + String message = prefix + e.getMessage() + " — statement: " + sql; + String sqlState = constraintSqlState(e); + return sqlState != null + ? new FeatureMutationConstraintException(message, e, sqlState) + : new IllegalStateException(message, e); + } + + /** + * The SQLSTATE of the first integrity-constraint violation in the chain, or null. Iterating the + * SQLException itself walks both the causal chain and the next-exception chain, which matters for + * the batch path: executeBatch() reports a BatchUpdateException whose actual error is a + * next-exception, not a cause. + */ + private static String constraintSqlState(SQLException e) { + for (Throwable t : e) { + if (t instanceof SQLException) { + String state = ((SQLException) t).getSQLState(); + if (state != null && state.startsWith("23")) { + return state; + } + } + } + return null; + } + private void flushBatch( Statement batchStmt, List batchedSql, List> batchedConsumers) { if (batchedSql.isEmpty()) { @@ -243,9 +273,7 @@ private void flushBatch( batchStmt.clearBatch(); harvestWarnings(batchStmt); } catch (SQLException e) { - throw new IllegalStateException( - "Batched mutation failed: " + e.getMessage() + " — first statement: " + batchedSql.get(0), - e); + throw mutationFailed("Batched mutation failed: ", batchedSql.get(0), e); } // Preserve the per-statement consumer contract: each batched statement returns no id, so // call consumers with null in order (in practice these are no-ops for child/junction/FK diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureMutationConstraintException.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureMutationConstraintException.java new file mode 100644 index 000000000..c8513ef14 --- /dev/null +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureMutationConstraintException.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.features.domain; + +/** + * Thrown when a mutation statement is rejected by the database because it violates an integrity + * constraint — a CHECK or foreign-key constraint, a unique index, or a trigger raising an error in + * SQLSTATE class 23. This is caused by the data the client sent, not by a bug or an infrastructure + * problem: it is reported to the client and rolls the transaction back, so callers should log it + * quietly, without a stack trace. + */ +public class FeatureMutationConstraintException extends RuntimeException { + + private final String sqlState; + + public FeatureMutationConstraintException(String message, Throwable cause, String sqlState) { + super(message, cause); + this.sqlState = sqlState; + } + + /** The SQLSTATE reported by the database, always in class 23. */ + public String getSqlState() { + return sqlState; + } +}