Skip to content

Commit 488ceaf

Browse files
authored
Merge pull request #27 from framefork/fix/unwrap-invocation-target-exception
Fix UndeclaredThrowableException masking exceptions during IDENTITY inserts
2 parents ae0ba35 + 3a38f74 commit 488ceaf

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)