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..2b6ec06e1 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); } } @@ -186,7 +184,7 @@ public List execute(List statements) { // Expected, configuration-driven failure (e.g. a check function RAISE EXCEPTION) — carry // the warnings collected so far so they survive the failure path. throw new FeatureMutationHookException( - "Hook statement failed: " + e.getMessage() + " — statement: " + sql, e, warnings); + "Hook statement failed: " + primaryMessage(e), e, warnings); } } return warnings; @@ -229,6 +227,57 @@ 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 sqlState = constraintSqlState(e); + if (sqlState != null) { + // Reported back to the client: keep the rejection itself, leave out the database's call + // context and the failing statement. Both are noise for the client and would disclose + // internal schema, function names and every attribute value of the statement; the statement + // is still available in the SQL debug log. + return new FeatureMutationConstraintException(prefix + primaryMessage(e), e, sqlState); + } + return new IllegalStateException(prefix + e.getMessage() + " — statement: " + sql, e); + } + + /** + * The primary message of a database error, without the call context the driver appends to {@link + * SQLException#getMessage()}. PostgreSQL primary messages are single-line while the context + * follows on its own lines, so cutting at the first line break is independent of the server's + * message locale (the context label is localised, e.g. {@code CONTEXT:} / {@code Wobei:}). + */ + private static String primaryMessage(SQLException e) { + String message = e.getMessage(); + if (message == null) { + return e.getClass().getSimpleName(); + } + int lineBreak = message.indexOf('\n'); + return (lineBreak < 0 ? message : message.substring(0, lineBreak)).trim(); + } + + /** + * 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 +292,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; + } +}