Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -162,8 +161,7 @@ public List<String> 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);
}
}

Expand All @@ -186,7 +184,7 @@ public List<String> execute(List<String> 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;
Expand Down Expand Up @@ -229,6 +227,57 @@ public List<String> 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<String> batchedSql, List<Consumer<String>> batchedConsumers) {
if (batchedSql.isEmpty()) {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading