From 589d0054753aa60756cf83e62727d433bfa1d279 Mon Sep 17 00:00:00 2001 From: Shihyu Ho Date: Thu, 16 Jul 2026 14:15:58 +0800 Subject: [PATCH] fix(mapper): unwrap reflective spec construction exceptions in toSpec SimpleSpecification.newSpec built specs via Constructor.newInstance under @SneakyThrows, which rethrew the InvocationTargetException wrapper verbatim. Constructor-time validation throws (TypeMismatchException from In/Between and IllegalArgumentException from Between) were therefore masked as InvocationTargetException at the SpecMapper.toSpec boundary, so downstream handlers keyed on TypeMismatchException never fired. Catch InvocationTargetException in newSpec and surface the original cause: RuntimeException/Error causes are rethrown unchanged, checked causes are wrapped in IllegalStateException. Add tests that exercise the mapper path (POJO -> SpecMapper.toSpec) for the In/non-Iterable and Between/1-element cases. Closes #177 Co-authored-by: Claude Opus 4.8 --- .../jpa/spec/domain/SimpleSpecification.java | 19 +++++++++++++-- .../data/jpa/spec/domain/BetweenTest.java | 24 +++++++++++++++++++ .../data/jpa/spec/domain/InTest.java | 22 +++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/SimpleSpecification.java b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/SimpleSpecification.java index 37c9aba9..64777bf7 100644 --- a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/SimpleSpecification.java +++ b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/SimpleSpecification.java @@ -26,6 +26,7 @@ import jakarta.persistence.criteria.Path; import jakarta.persistence.criteria.Root; +import java.lang.reflect.InvocationTargetException; import java.util.Optional; import java.util.StringJoiner; import lombok.Builder; @@ -63,8 +64,22 @@ private static Specification newSpec( @NonNull Class domainClass, @NonNull String path, @NonNull Object value) { - return accessibleConstructor(domainClass, Context.class, String.class, Object.class) - .newInstance(context, path, value); + try { + return accessibleConstructor(domainClass, Context.class, String.class, Object.class) + .newInstance(context, path, value); + } catch (InvocationTargetException e) { + // Constructor.newInstance wraps any exception thrown inside the constructor in an + // InvocationTargetException; surface the original cause (e.g. TypeMismatchException, + // IllegalArgumentException) so it reaches SpecMapper.toSpec callers. + var cause = e.getCause(); + if (cause instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (cause instanceof Error error) { + throw error; + } + throw new IllegalStateException(cause); + } } @SuppressWarnings({"unchecked"}) diff --git a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/BetweenTest.java b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/BetweenTest.java index 15c89d6f..60d6046e 100644 --- a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/BetweenTest.java +++ b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/BetweenTest.java @@ -21,13 +21,19 @@ package tw.com.softleader.data.jpa.spec.domain; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static tw.com.softleader.data.jpa.spec.IntegrationTest.TestApplication.noopContext; import java.time.LocalDate; import java.util.Arrays; +import java.util.List; +import lombok.Builder; +import lombok.Data; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import tw.com.softleader.data.jpa.spec.IntegrationTest; +import tw.com.softleader.data.jpa.spec.SpecMapper; +import tw.com.softleader.data.jpa.spec.annotation.Spec; import tw.com.softleader.data.jpa.spec.usecase.Customer; import tw.com.softleader.data.jpa.spec.usecase.CustomerRepository; @@ -49,4 +55,22 @@ void test() { var actual = repository.findAll(spec); assertThat(actual).hasSize(1).contains(matt); } + + @Test + void oneElementThroughMapper() { + var mapper = SpecMapper.builder().build(); + var birthday = List.of(LocalDate.now()); + var criteria = BetweenCriteria.builder().birthday(birthday).build(); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> mapper.toSpec(criteria, Customer.class)) + .withMessage("@Between expected exact 2 elements, but was " + birthday); + } + + @Builder + @Data + static class BetweenCriteria { + + @Spec(value = Between.class) + List birthday; + } } diff --git a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/InTest.java b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/InTest.java index d01835e2..b76149d4 100644 --- a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/InTest.java +++ b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/InTest.java @@ -25,9 +25,13 @@ import static tw.com.softleader.data.jpa.spec.IntegrationTest.TestApplication.noopContext; import java.util.Arrays; +import lombok.Builder; +import lombok.Data; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import tw.com.softleader.data.jpa.spec.IntegrationTest; +import tw.com.softleader.data.jpa.spec.SpecMapper; +import tw.com.softleader.data.jpa.spec.annotation.Spec; import tw.com.softleader.data.jpa.spec.usecase.Customer; import tw.com.softleader.data.jpa.spec.usecase.CustomerRepository; @@ -56,4 +60,22 @@ void typeMismatch() { .withMessage( "Failed to convert value of type 'java.lang.Object' to required type 'java.lang.Iterable'"); } + + @Test + void typeMismatchThroughMapper() { + var mapper = SpecMapper.builder().build(); + var criteria = InCriteria.builder().name("matt").build(); + assertThatExceptionOfType(TypeMismatchException.class) + .isThrownBy(() -> mapper.toSpec(criteria, Customer.class)) + .withMessage( + "Failed to convert value of type 'java.lang.String' to required type 'java.lang.Iterable'"); + } + + @Builder + @Data + static class InCriteria { + + @Spec(path = "name", value = In.class) + String name; + } }