Skip to content

Commit 3a38f74

Browse files
fprochazkaclaude
andcommitted
Fix UndeclaredThrowableException masking real exceptions during IDENTITY inserts
JDK Proxy InvocationHandlers in ObjectBigIntIdIdentityGenerator were not unwrapping InvocationTargetException from Method.invoke(), causing the JDK Proxy to wrap it in UndeclaredThrowableException (with null message). This masked real exceptions like ConstraintViolationException, making debugging difficult and breaking catch blocks that expect specific types. Fixes #26 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ae0ba35 commit 3a38f74

9 files changed

Lines changed: 481 additions & 5 deletions

File tree

modules/typed-ids-hibernate-61/src/main/java/org/framefork/typedIds/bigint/hibernate/id/ObjectBigIntIdIdentityGenerator.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import org.jspecify.annotations.Nullable;
2121

2222
import java.lang.reflect.InvocationHandler;
23+
import java.lang.reflect.InvocationTargetException;
2324
import java.lang.reflect.Method;
2425
import java.lang.reflect.Proxy;
2526
import java.util.Objects;
@@ -96,7 +97,11 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl
9697
}
9798

9899
// For all other methods, delegate to the original persister
99-
return method.invoke(persister, args);
100+
try {
101+
return method.invoke(persister, args);
102+
} catch (InvocationTargetException e) {
103+
throw e.getCause();
104+
}
100105
}
101106
}
102107
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package org.framefork.typedIds.bigint.hibernate;
2+
3+
import org.framefork.typedIds.bigint.hibernate.basic.BigIntDbIdentityGeneratedUniqueTitleEntity;
4+
import org.framefork.typedIds.hibernate.tests.AbstractMySQLIntegrationTest;
5+
import org.hibernate.exception.ConstraintViolationException;
6+
import org.junit.jupiter.api.Test;
7+
8+
import java.lang.reflect.UndeclaredThrowableException;
9+
10+
import static org.assertj.core.api.Assertions.assertThat;
11+
import static org.junit.jupiter.api.Assertions.assertThrows;
12+
13+
/**
14+
* Verifies that exceptions thrown during IDENTITY-based inserts
15+
* propagate without being wrapped in {@link UndeclaredThrowableException}.
16+
*
17+
* <p>The {@code ObjectBigIntIdIdentityGenerator} uses JDK Proxy to intercept
18+
* calls to Hibernate internals. Without proper unwrapping, {@link java.lang.reflect.Method#invoke}
19+
* wraps checked exceptions in {@link java.lang.reflect.InvocationTargetException},
20+
* which the JDK Proxy further wraps in {@link UndeclaredThrowableException},
21+
* masking the real cause (e.g. {@link org.hibernate.exception.ConstraintViolationException}).
22+
*/
23+
final class IdentityGeneratorExceptionPropagationTest extends AbstractMySQLIntegrationTest
24+
{
25+
26+
@Override
27+
protected Class<?>[] entities()
28+
{
29+
return new Class<?>[]{
30+
BigIntDbIdentityGeneratedUniqueTitleEntity.class,
31+
};
32+
}
33+
34+
@Test
35+
public void constraintViolation_shouldNotBeWrappedInUndeclaredThrowableException()
36+
{
37+
// First, insert an entity with a unique title
38+
doInJPA(em -> {
39+
em.persist(new BigIntDbIdentityGeneratedUniqueTitleEntity("duplicate-title"));
40+
em.flush();
41+
});
42+
43+
// Then, try to insert another entity with the same title to trigger a unique constraint violation
44+
var exception = assertThrows(
45+
Exception.class,
46+
() -> doInJPA(em -> {
47+
em.persist(new BigIntDbIdentityGeneratedUniqueTitleEntity("duplicate-title"));
48+
em.flush();
49+
})
50+
);
51+
52+
// The exception must NOT be UndeclaredThrowableException - that would mean
53+
// InvocationTargetException was not properly unwrapped in the JDK Proxy handler
54+
assertThat(exception)
55+
.as("Exception should not be wrapped in UndeclaredThrowableException")
56+
.isNotInstanceOf(UndeclaredThrowableException.class);
57+
58+
// Verify the real constraint violation exception is present in the chain
59+
assertThat(hasExceptionInChain(exception, ConstraintViolationException.class))
60+
.as("ConstraintViolationException should be in the exception chain, but got: %s", exception)
61+
.isTrue();
62+
}
63+
64+
private static boolean hasExceptionInChain(final Throwable throwable, final Class<? extends Throwable> expectedType)
65+
{
66+
Throwable current = throwable;
67+
while (current != null) {
68+
if (expectedType.isInstance(current)) {
69+
return true;
70+
}
71+
current = current.getCause();
72+
}
73+
return false;
74+
}
75+
76+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package org.framefork.typedIds.bigint.hibernate.basic;
2+
3+
import jakarta.persistence.Column;
4+
import jakarta.persistence.Entity;
5+
import jakarta.persistence.GeneratedValue;
6+
import jakarta.persistence.GenerationType;
7+
import jakarta.persistence.Table;
8+
import org.framefork.typedIds.bigint.ObjectBigIntId;
9+
import org.framefork.typedIds.bigint.hibernate.ObjectBigIntIdType;
10+
import org.hibernate.annotations.Type;
11+
import org.jspecify.annotations.Nullable;
12+
13+
@Entity
14+
@Table(name = BigIntDbIdentityGeneratedUniqueTitleEntity.TABLE_NAME)
15+
public class BigIntDbIdentityGeneratedUniqueTitleEntity
16+
{
17+
18+
public static final String TABLE_NAME = "bigint_db_identity_generated_unique_title";
19+
20+
@jakarta.persistence.Id
21+
@GeneratedValue(strategy = GenerationType.IDENTITY)
22+
@Column(nullable = false)
23+
@Type(ObjectBigIntIdType.class)
24+
@Nullable
25+
private Id id;
26+
27+
@Column(nullable = false, unique = true)
28+
private String title;
29+
30+
public BigIntDbIdentityGeneratedUniqueTitleEntity(final String title)
31+
{
32+
this.title = title;
33+
}
34+
35+
@SuppressWarnings("NullAway")
36+
protected BigIntDbIdentityGeneratedUniqueTitleEntity()
37+
{
38+
}
39+
40+
@Nullable
41+
public Id getId()
42+
{
43+
return id;
44+
}
45+
46+
public String getTitle()
47+
{
48+
return title;
49+
}
50+
51+
public static final class Id extends ObjectBigIntId<Id>
52+
{
53+
54+
private Id(final long inner)
55+
{
56+
super(inner);
57+
}
58+
59+
public static Id random()
60+
{
61+
return ObjectBigIntId.randomBigInt(Id::new);
62+
}
63+
64+
public static Id from(final String value)
65+
{
66+
return ObjectBigIntId.fromString(Id::new, value);
67+
}
68+
69+
public static Id from(final long value)
70+
{
71+
return ObjectBigIntId.fromLong(Id::new, value);
72+
}
73+
74+
}
75+
76+
}

modules/typed-ids-hibernate-62/src/main/java/org/framefork/typedIds/bigint/hibernate/id/ObjectBigIntIdIdentityGenerator.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import org.jspecify.annotations.Nullable;
2626

2727
import java.lang.reflect.InvocationHandler;
28+
import java.lang.reflect.InvocationTargetException;
2829
import java.lang.reflect.Method;
2930
import java.lang.reflect.Proxy;
3031
import java.sql.PreparedStatement;
@@ -98,7 +99,11 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl
9899
}
99100

100101
// For all other methods, delegate to the original persister
101-
return method.invoke(persister, args);
102+
try {
103+
return method.invoke(persister, args);
104+
} catch (InvocationTargetException e) {
105+
throw e.getCause();
106+
}
102107
}
103108
}
104109
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package org.framefork.typedIds.bigint.hibernate;
2+
3+
import org.framefork.typedIds.bigint.hibernate.basic.BigIntDbIdentityGeneratedUniqueTitleEntity;
4+
import org.framefork.typedIds.hibernate.tests.AbstractMySQLIntegrationTest;
5+
import org.hibernate.exception.ConstraintViolationException;
6+
import org.junit.jupiter.api.Test;
7+
8+
import java.lang.reflect.UndeclaredThrowableException;
9+
10+
import static org.assertj.core.api.Assertions.assertThat;
11+
import static org.junit.jupiter.api.Assertions.assertThrows;
12+
13+
/**
14+
* Verifies that exceptions thrown during IDENTITY-based inserts
15+
* propagate without being wrapped in {@link UndeclaredThrowableException}.
16+
*
17+
* <p>The {@code ObjectBigIntIdIdentityGenerator} uses JDK Proxy to intercept
18+
* calls to Hibernate internals. Without proper unwrapping, {@link java.lang.reflect.Method#invoke}
19+
* wraps checked exceptions in {@link java.lang.reflect.InvocationTargetException},
20+
* which the JDK Proxy further wraps in {@link UndeclaredThrowableException},
21+
* masking the real cause (e.g. {@link org.hibernate.exception.ConstraintViolationException}).
22+
*/
23+
final class IdentityGeneratorExceptionPropagationTest extends AbstractMySQLIntegrationTest
24+
{
25+
26+
@Override
27+
protected Class<?>[] entities()
28+
{
29+
return new Class<?>[]{
30+
BigIntDbIdentityGeneratedUniqueTitleEntity.class,
31+
};
32+
}
33+
34+
@Test
35+
public void constraintViolation_shouldNotBeWrappedInUndeclaredThrowableException()
36+
{
37+
// First, insert an entity with a unique title
38+
doInJPA(em -> {
39+
em.persist(new BigIntDbIdentityGeneratedUniqueTitleEntity("duplicate-title"));
40+
em.flush();
41+
});
42+
43+
// Then, try to insert another entity with the same title to trigger a unique constraint violation
44+
var exception = assertThrows(
45+
Exception.class,
46+
() -> doInJPA(em -> {
47+
em.persist(new BigIntDbIdentityGeneratedUniqueTitleEntity("duplicate-title"));
48+
em.flush();
49+
})
50+
);
51+
52+
// The exception must NOT be UndeclaredThrowableException - that would mean
53+
// InvocationTargetException was not properly unwrapped in the JDK Proxy handler
54+
assertThat(exception)
55+
.as("Exception should not be wrapped in UndeclaredThrowableException")
56+
.isNotInstanceOf(UndeclaredThrowableException.class);
57+
58+
// Verify the real constraint violation exception is present in the chain
59+
assertThat(hasExceptionInChain(exception, ConstraintViolationException.class))
60+
.as("ConstraintViolationException should be in the exception chain, but got: %s", exception)
61+
.isTrue();
62+
}
63+
64+
private static boolean hasExceptionInChain(final Throwable throwable, final Class<? extends Throwable> expectedType)
65+
{
66+
Throwable current = throwable;
67+
while (current != null) {
68+
if (expectedType.isInstance(current)) {
69+
return true;
70+
}
71+
current = current.getCause();
72+
}
73+
return false;
74+
}
75+
76+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package org.framefork.typedIds.bigint.hibernate.basic;
2+
3+
import jakarta.persistence.Column;
4+
import jakarta.persistence.Entity;
5+
import jakarta.persistence.GeneratedValue;
6+
import jakarta.persistence.GenerationType;
7+
import jakarta.persistence.Table;
8+
import org.framefork.typedIds.bigint.ObjectBigIntId;
9+
import org.framefork.typedIds.bigint.hibernate.ObjectBigIntIdType;
10+
import org.hibernate.annotations.Type;
11+
import org.jspecify.annotations.Nullable;
12+
13+
@Entity
14+
@Table(name = BigIntDbIdentityGeneratedUniqueTitleEntity.TABLE_NAME)
15+
public class BigIntDbIdentityGeneratedUniqueTitleEntity
16+
{
17+
18+
public static final String TABLE_NAME = "bigint_db_identity_generated_unique_title";
19+
20+
@jakarta.persistence.Id
21+
@GeneratedValue(strategy = GenerationType.IDENTITY)
22+
@Column(nullable = false)
23+
@Type(ObjectBigIntIdType.class)
24+
@Nullable
25+
private Id id;
26+
27+
@Column(nullable = false, unique = true)
28+
private String title;
29+
30+
public BigIntDbIdentityGeneratedUniqueTitleEntity(final String title)
31+
{
32+
this.title = title;
33+
}
34+
35+
@SuppressWarnings("NullAway")
36+
protected BigIntDbIdentityGeneratedUniqueTitleEntity()
37+
{
38+
}
39+
40+
@Nullable
41+
public Id getId()
42+
{
43+
return id;
44+
}
45+
46+
public String getTitle()
47+
{
48+
return title;
49+
}
50+
51+
public static final class Id extends ObjectBigIntId<Id>
52+
{
53+
54+
private Id(final long inner)
55+
{
56+
super(inner);
57+
}
58+
59+
public static Id random()
60+
{
61+
return ObjectBigIntId.randomBigInt(Id::new);
62+
}
63+
64+
public static Id from(final String value)
65+
{
66+
return ObjectBigIntId.fromString(Id::new, value);
67+
}
68+
69+
public static Id from(final long value)
70+
{
71+
return ObjectBigIntId.fromLong(Id::new, value);
72+
}
73+
74+
}
75+
76+
}

modules/typed-ids-hibernate-63/src/main/java/org/framefork/typedIds/bigint/hibernate/id/ObjectBigIntIdIdentityGenerator.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import org.jspecify.annotations.Nullable;
1616

1717
import java.lang.reflect.InvocationHandler;
18+
import java.lang.reflect.InvocationTargetException;
1819
import java.lang.reflect.Method;
1920
import java.lang.reflect.Proxy;
2021
import java.util.Objects;
@@ -87,7 +88,11 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl
8788
}
8889

8990
// For all other methods, delegate to the original persister
90-
return method.invoke(persister, args);
91+
try {
92+
return method.invoke(persister, args);
93+
} catch (InvocationTargetException e) {
94+
throw e.getCause();
95+
}
9196
}
9297
}
9398
);
@@ -103,8 +108,13 @@ private InsertGeneratedIdentifierDelegate proxyIdentifierDelegate(final InsertGe
103108
@Override
104109
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
105110
{
106-
// For all other methods, delegate to the original persister
107-
Object result = method.invoke(delegate, args);
111+
// For all other methods, delegate to the original delegate
112+
Object result;
113+
try {
114+
result = method.invoke(delegate, args);
115+
} catch (InvocationTargetException e) {
116+
throw e.getCause();
117+
}
108118

109119
if ("performInsert".equals(method.getName())) {
110120
var idType = Objects.requireNonNull(objectBigIntIdType, "objectBigIntIdType must not be null");

0 commit comments

Comments
 (0)