From b5895c6acca973f7295699c836b55d74a27e431c Mon Sep 17 00:00:00 2001 From: Shihyu Ho Date: Mon, 20 Jul 2026 17:22:21 +0800 Subject: [PATCH] fix(mapper): validate join machinery instead of silently mis-resolving The join resolvers assumed well-formed, well-ordered input and silently mis-handled anything else. Validate or fail loud in one pass: - Join.toPredicate accumulates the distinct flag rather than overwriting it, so a later join can no longer undo an earlier distinct=true. - Registering an alias that already exists now compares the stored parent, attribute and joinType, and throws when they conflict instead of keeping the first definition. - Join paths deeper than two segments throw instead of silently joining only the first two. - A dotted path whose first segment resolves to neither a registered join/fetch alias nor a root attribute throws a message naming the alias and the declaration-order requirement, rather than falling back to a same-named attribute on the root. - A missing JoinContext (direct construction outside SpecMapper) throws a message explaining the mapper-pipeline requirement instead of a bare NoSuchElementException. - Fetch aliases resolve through the fetch node itself, which carries the declared joinType, so a non-INNER @JoinFetch no longer restricts the content query differently from the count query. - Join/fetch bookkeeping is keyed weakly by Root so a reused Specification no longer accumulates one entry per execution. Several silent-wrong-result paths now throw. Co-authored-by: Claude Opus 4.8 --- .../data/jpa/spec/SpecJoinContext.java | 50 ++++- .../softleader/data/jpa/spec/domain/Join.java | 45 ++++- .../jpa/spec/domain/SimpleSpecification.java | 42 ++++- .../JoinFetchSpecificationResolverTest.java | 34 ++++ .../spec/JoinSpecificationResolverTest.java | 171 ++++++++++++++++++ .../data/jpa/spec/SpecJoinContextTest.java | 43 +++++ .../ConstructSimpleSpecificationTest.java | 31 ++++ 7 files changed, 397 insertions(+), 19 deletions(-) diff --git a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/SpecJoinContext.java b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/SpecJoinContext.java index b7275b63..6c80d82e 100644 --- a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/SpecJoinContext.java +++ b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/SpecJoinContext.java @@ -23,11 +23,14 @@ import static java.util.Collections.synchronizedMap; import static java.util.Optional.ofNullable; +import jakarta.persistence.criteria.Fetch; import jakarta.persistence.criteria.Join; import jakarta.persistence.criteria.Root; import java.lang.annotation.Annotation; +import java.lang.ref.WeakReference; import java.util.HashMap; import java.util.Map; +import java.util.WeakHashMap; import lombok.NonNull; import org.springframework.lang.Nullable; import tw.com.softleader.data.jpa.spec.domain.JoinContext; @@ -38,8 +41,23 @@ class SpecJoinContext implements JoinContext { private final Map handled = synchronizedMap(new HashMap<>()); - private final Map> joined = synchronizedMap(new HashMap<>()); - private final Map fetched = synchronizedMap(new HashMap<>()); + + /* + * Join and fetch bookkeeping belongs to the single query execution that created it: a reused + * Specification is executed against a brand new Root every time, so entries kept per Root would + * pile up for as long as that Specification lives. + * + * The Root is therefore a weak key, which lets an entry die together with the criteria tree it + * describes. The criteria nodes kept as values refer back to their Root, so they are held weakly + * as well - a strong value would keep its own key reachable and defeat the weak key entirely + * (see the WeakHashMap javadoc). That is safe because a Root owns every join and fetch built + * from it, which keeps the referents alive for as long as the execution can still reach them. + */ + private final Map, Map>>> joined = + synchronizedMap(new WeakHashMap<>()); + + private final Map, Map> fetched = + synchronizedMap(new WeakHashMap<>()); @Override public boolean hasHandled( @@ -54,22 +72,34 @@ public void markHandled(@NonNull Object target, @Nullable Object field, @NonNull @Override public void putIfAbsent(@NonNull Root root, @NonNull String alias, @NonNull Join join) { - joined.putIfAbsent(new JoinKey(root, alias), join); + byAlias(joined, root).putIfAbsent(alias, new WeakReference<>(join)); } @Override public void putIfAbsent(@NonNull Root root, @NonNull String alias, @NonNull FetchRef ref) { - fetched.putIfAbsent(new FetchKey(root, alias), ref); + byAlias(fetched, root) + .putIfAbsent(alias, new FetchEntry(new WeakReference<>(ref.fetch()), ref.paths())); } @Override public Join getJoin(@NonNull Root root, @NonNull String alias) { - return joined.get(new JoinKey(root, alias)); + return ofNullable(joined.get(root)) + .map(aliases -> aliases.get(alias)) + .map(WeakReference::get) + .orElse(null); } @Override public FetchRef getFetch(@NonNull Root root, @NonNull String alias) { - return fetched.get(new FetchKey(root, alias)); + return ofNullable(fetched.get(root)) + .map(aliases -> aliases.get(alias)) + .map(FetchEntry::toRef) + .orElse(null); + } + + private static Map byAlias( + @NonNull Map, Map> byRoot, @NonNull Root root) { + return byRoot.computeIfAbsent(root, key -> synchronizedMap(new HashMap<>())); } record HandleKey(@NonNull String target, @Nullable String field, @NonNull Annotation def) { @@ -82,7 +112,11 @@ static String identityHex(@Nullable Object obj) { } } - record JoinKey(@NonNull Root root, @NonNull String alias) {} + record FetchEntry(@NonNull WeakReference> fetch, @NonNull String[] paths) { - record FetchKey(@NonNull Root root, @NonNull String alias) {} + @Nullable + FetchRef toRef() { + return ofNullable(fetch.get()).map(f -> new FetchRef(f, paths)).orElse(null); + } + } } diff --git a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/Join.java b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/Join.java index dbe4a9f7..76e1cfb6 100644 --- a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/Join.java +++ b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/Join.java @@ -25,6 +25,7 @@ import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.From; import jakarta.persistence.criteria.JoinType; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; @@ -81,7 +82,9 @@ public Join( public Predicate toPredicate( @NonNull Root root, @Nullable CriteriaQuery query, @NonNull CriteriaBuilder builder) { if (query != null) { - query.distinct(distinct); + // accumulate rather than overwrite: every join contributes to the query, so a join declared + // with distinct=false must not silently undo the distinct=true of an earlier one + query.distinct(query.isDistinct() || distinct); } join(root); return null; @@ -89,17 +92,23 @@ public Predicate toPredicate( private void join(Root root) { var jc = context.getAs(CTX_JOIN, JoinContext.class); - - // check if alias already exists, skip creating a new join - if (jc.getJoin(root, alias) != null) { - return; - } + var existing = jc.getJoin(root, alias); if (!pathToJoinOn.contains(".")) { + // alias already exists, reuse it as long as it stands for the very same join + if (existing != null) { + verifyNoConflict(existing, root, pathToJoinOn); + return; + } jc.putIfAbsent(root, alias, root.join(pathToJoinOn, joinType)); return; } var byDot = pathToJoinOn.split("\\."); + if (byDot.length != 2) { + throw new IllegalArgumentException( + "Join path: '%s' (alias: '%s') consists of %d segments, but a join path is limited to 2 segments in the form of '.'! Define an intermediate join for each additional segment and refer to its alias here." + .formatted(pathToJoinOn, alias, byDot.length)); + } var extractedAlias = byDot[0]; var joined = jc.getJoin(root, extractedAlias); @@ -110,6 +119,30 @@ private void join(Root root) { } var extractedPathToJoin = byDot[1]; + // alias already exists, reuse it as long as it stands for the very same join + if (existing != null) { + verifyNoConflict(existing, joined, extractedPathToJoin); + return; + } jc.putIfAbsent(root, alias, joined.join(extractedPathToJoin, joinType)); } + + private void verifyNoConflict( + @NonNull jakarta.persistence.criteria.Join existing, + @NonNull From parent, + @NonNull String attributeName) { + if (existing.getParent() == parent + && existing.getAttribute().getName().equals(attributeName) + && existing.getJoinType() == joinType) { + return; + } + throw new IllegalArgumentException( + "Conflicting join definitions share the alias: '%s'! It is already defined as a %s join on the attribute: '%s', so it can not be redefined as a %s join on the path: '%s'. Every join alias must be declared with the same path and joinType." + .formatted( + alias, + existing.getJoinType(), + existing.getAttribute().getName(), + joinType, + pathToJoinOn)); + } } 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 64777bf7..e2a354ab 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 @@ -27,6 +27,7 @@ import jakarta.persistence.criteria.Path; import jakarta.persistence.criteria.Root; import java.lang.reflect.InvocationTargetException; +import java.util.NoSuchElementException; import java.util.Optional; import java.util.StringJoiner; import lombok.Builder; @@ -98,21 +99,30 @@ protected Path getPath(@NonNull Root root) { } private Path getExpr(@NonNull Root root, @NonNull String field) { - return getJoin(root, field).or(() -> getFetch(root, field)).orElseGet(() -> root.get(field)); + return getJoin(root, field) + .or(() -> getFetch(root, field)) + .orElseGet(() -> getAttribute(root, field)); } @SuppressWarnings({"unchecked"}) private Optional> getJoin(@NonNull Root root, @NonNull String field) { - return ofNullable(context.getAs(CTX_JOIN, JoinContext.class).getJoin(root, field)) - .map(joined -> (Path) joined); + return ofNullable(joinContext().getJoin(root, field)).map(joined -> (Path) joined); } private Optional> getFetch(@NonNull Root root, @NonNull String field) { - return ofNullable(context.getAs(CTX_JOIN, JoinContext.class).getFetch(root, field)) - .map(ref -> getFetchPath(root, ref)); + return ofNullable(joinContext().getFetch(root, field)).map(ref -> getFetchPath(root, ref)); } + @SuppressWarnings({"unchecked"}) private Path getFetchPath(@NonNull Root root, @NonNull FetchRef ref) { + // Resolve through the fetch node itself, since it carries the joinType the fetch was declared + // with. Re-navigating the paths from the root would emit an implicit inner join instead, so a + // non-INNER @JoinFetch would restrict the content query differently from the count query - + // which resolves the very same alias as a real join - and page totals would disagree with the + // page content. + if (ref.fetch() instanceof Path fetched) { + return (Path) fetched; + } Path current = root; for (var path : ref.paths()) { current = current.get(path); @@ -120,6 +130,28 @@ private Path getFetchPath(@NonNull Root root, @NonNull FetchRef ref) { return current; } + private Path getAttribute(@NonNull Root root, @NonNull String field) { + try { + return root.get(field); + } catch (RuntimeException e) { + throw new IllegalArgumentException( + "Unable to resolve: '%s' of the path: '%s'! It is neither a join alias registered on this query nor an attribute of %s. If '%s' is meant to be a join alias, make sure the @Join or @JoinFetch defining it is declared before this spec - declaration order matters, and a join declared on a null-valued field is never applied." + .formatted(field, path, root.getJavaType().getSimpleName(), field), + e); + } + } + + private JoinContext joinContext() { + try { + return context.getAs(CTX_JOIN, JoinContext.class); + } catch (NoSuchElementException e) { + throw new IllegalStateException( + "No JoinContext registered under the context key: '%s'! Resolving the multi-segment path: '%s' needs the join registry that SpecMapper populates while it builds the specification, so build this spec through SpecMapper.toSpec(..) instead of constructing it directly." + .formatted(CTX_JOIN, path), + e); + } + } + @Override public String toString() { return new StringJoiner(", ", getClass().getSimpleName() + "[", "]") diff --git a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/JoinFetchSpecificationResolverTest.java b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/JoinFetchSpecificationResolverTest.java index 4dec1f4a..98d46346 100644 --- a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/JoinFetchSpecificationResolverTest.java +++ b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/JoinFetchSpecificationResolverTest.java @@ -25,6 +25,7 @@ import static org.mockito.Mockito.spy; import jakarta.persistence.EntityManager; +import jakarta.persistence.criteria.JoinType; import java.util.Collection; import lombok.AllArgsConstructor; import lombok.Builder; @@ -34,12 +35,14 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; import tw.com.softleader.data.jpa.spec.annotation.JoinFetch; import tw.com.softleader.data.jpa.spec.annotation.JoinFetch.JoinFetches; import tw.com.softleader.data.jpa.spec.annotation.Spec; import tw.com.softleader.data.jpa.spec.domain.Conjunction; import tw.com.softleader.data.jpa.spec.domain.Equals; import tw.com.softleader.data.jpa.spec.domain.In; +import tw.com.softleader.data.jpa.spec.domain.IsNull; import tw.com.softleader.data.jpa.spec.domain.Like; import tw.com.softleader.data.jpa.spec.usecase.*; @@ -350,6 +353,28 @@ void duplicateAliasJoinFetchOnField() { assertThat(root.getFetches()).hasSize(1); } + @DisplayName("非 INNER 的 JoinFetch, count 與 content 應以相同的 join 語意解析 alias") + @Test + void nonInnerJoinFetchShouldKeepCountAndContentInSync() { + repository.save( + Customer.builder().name("matt").order(Order.builder().itemName("Pizza").build()).build()); + // bob 沒有任何 order, 只有 LEFT JOIN 才找得到他 + var bob = repository.save(Customer.builder().name("bob").build()); + + var criteria = LeftJoinFetchOnField.builder().withoutItem(true).build(); + + var spec = mapper.toSpec(criteria, Customer.class); + + var content = repository.findAll(spec); + assertThat(content).containsExactly(bob); + assertThat(repository.count(spec)).isEqualTo(content.size()); + + // page size 1 讓 Spring Data 無法省略 count query + var page = repository.findAll(spec, PageRequest.of(0, 1)); + assertThat(page.getTotalElements()).isEqualTo(content.size()); + assertThat(page.getContent()).containsExactly(bob); + } + @JoinFetch(path = "orders") @AllArgsConstructor @Data @@ -418,4 +443,13 @@ public static class DuplicateAliasJoinFetchOnField { @Spec(path = "order.itemName", value = Like.class) String itemName; } + + @Builder + @Data + public static class LeftJoinFetchOnField { + + @JoinFetch(path = "orders", alias = "o", joinType = JoinType.LEFT) + @Spec(path = "o.itemName", value = IsNull.class) + Boolean withoutItem; + } } diff --git a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/JoinSpecificationResolverTest.java b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/JoinSpecificationResolverTest.java index ce626afa..d69a315c 100644 --- a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/JoinSpecificationResolverTest.java +++ b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/JoinSpecificationResolverTest.java @@ -21,10 +21,12 @@ package tw.com.softleader.data.jpa.spec; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.InstanceOfAssertFactories.LIST; import static org.mockito.Mockito.spy; import jakarta.persistence.EntityManager; +import jakarta.persistence.criteria.JoinType; import java.util.Collection; import lombok.Builder; import lombok.Data; @@ -305,6 +307,129 @@ void duplicateAliasJoinOnField() { assertThat(root.getJoins()).hasSize(1); } + @DisplayName("多個 Join 的 distinct 應累加, 不應被最後執行的 Join 覆寫") + @Test + @SuppressWarnings("DataFlowIssue") + void distinctShouldBeAccumulatedAcrossJoins() { + + var spec = mapper.toSpec(new MixedDistinctJoinsOnClassOnly(), Customer.class); + + var cb = entityManager.getCriteriaBuilder(); + var query = cb.createQuery(Customer.class); + var root = query.from(Customer.class); + + spec.toPredicate(root, query, cb); + + assertThat(query.isDistinct()).isTrue(); + } + + @DisplayName("相同 alias 但 path 不同的 Join 應拋出例外") + @Test + @SuppressWarnings("DataFlowIssue") + void conflictingPathOnSameAliasShouldThrow() { + + var criteria = ConflictingPathAliasOnField.builder().orderId(1L).badgeId(2L).build(); + + var spec = mapper.toSpec(criteria, Customer.class); + + var cb = entityManager.getCriteriaBuilder(); + var query = cb.createQuery(Customer.class); + var root = query.from(Customer.class); + + assertThatIllegalArgumentException() + .isThrownBy(() -> spec.toPredicate(root, query, cb)) + .withMessageContaining("shared") + .withMessageContaining("orders") + .withMessageContaining("badges"); + } + + @DisplayName("相同 alias 但 joinType 不同的 Join 應拋出例外") + @Test + @SuppressWarnings("DataFlowIssue") + void conflictingJoinTypeOnSameAliasShouldThrow() { + + var criteria = ConflictingJoinTypeAliasOnField.builder().orderId(1L).itemName("Pizza").build(); + + var spec = mapper.toSpec(criteria, Customer.class); + + var cb = entityManager.getCriteriaBuilder(); + var query = cb.createQuery(Customer.class); + var root = query.from(Customer.class); + + assertThatIllegalArgumentException() + .isThrownBy(() -> spec.toPredicate(root, query, cb)) + .withMessageContaining("order") + .withMessageContaining(JoinType.INNER.name()) + .withMessageContaining(JoinType.LEFT.name()); + } + + @DisplayName("超過兩層的 Join path 應拋出例外") + @Test + @SuppressWarnings("DataFlowIssue") + void joinPathWithMoreThanTwoSegmentsShouldThrow() { + + var spec = mapper.toSpec(new ThreeSegmentJoinPathOnClassOnly(), Customer.class); + + var cb = entityManager.getCriteriaBuilder(); + var query = cb.createQuery(Customer.class); + var root = query.from(Customer.class); + + assertThatIllegalArgumentException() + .isThrownBy(() -> spec.toPredicate(root, query, cb)) + .withMessageContaining("o.tags.name") + .withMessageContaining("3 segments") + .withMessageContaining("2 segments"); + } + + @DisplayName("Spec 參照到未註冊的 join alias 應拋出例外, 而不是靜默地查 root 的同名屬性") + @Test + @SuppressWarnings("DataFlowIssue") + void unregisteredJoinAliasShouldThrow() { + + // orderId 為 null, 因此定義在它身上的 join 不會被套用, alias 'o' 也就不會被註冊 + var criteria = NullValuedJoinAliasOnField.builder().itemName("Pizza").build(); + + var spec = mapper.toSpec(criteria, Customer.class); + + var cb = entityManager.getCriteriaBuilder(); + var query = cb.createQuery(Customer.class); + var root = query.from(Customer.class); + + assertThatIllegalArgumentException() + .isThrownBy(() -> spec.toPredicate(root, query, cb)) + .withMessageContaining("o.itemName") + .withMessageContaining("declared before this spec") + .withMessageContaining("null-valued field"); + } + + @DisplayName("重複使用同一個 Specification 執行多次, 結果應一致") + @Test + void reusedSpecificationShouldStayConsistentAcrossExecutions() { + var matt = + repository.save( + Customer.builder() + .name("matt") + .order(Order.builder().itemName("Pizza").build()) + .build()); + var mary = + repository.save( + Customer.builder() + .name("mary") + .order(Order.builder().itemName("Hamburger").build()) + .build()); + repository.save( + Customer.builder().name("bob").order(Order.builder().itemName("Coke").build()).build()); + + var criteria = SingleJoinOnField.builder().item("Pizza").item("Hamburger").build(); + + var spec = mapper.toSpec(criteria, Customer.class); + + for (var execution = 0; execution < 3; execution++) { + assertThat(repository.findAll(spec)).hasSize(2).contains(matt, mary); + assertThat(repository.count(spec)).isEqualTo(2); + } + } + @Builder @Data public static class SingleJoinOnField { @@ -365,4 +490,50 @@ public static class DuplicateAliasJoinOnField { @Spec(path = "order.itemName", value = Like.class) String itemName; } + + @Join(path = "orders", alias = "o", distinct = true) + @Join(path = "badges", alias = "b", distinct = false) + public static class MixedDistinctJoinsOnClassOnly {} + + @Join(path = "orders", alias = "o") + @Join(path = "o.tags.name", alias = "deep") + public static class ThreeSegmentJoinPathOnClassOnly {} + + @Builder + @Data + public static class ConflictingPathAliasOnField { + + @Join(path = "orders", alias = "shared") + @Spec(path = "shared.id") + Long orderId; + + @Join(path = "badges", alias = "shared") + @Spec(path = "shared.id") + Long badgeId; + } + + @Builder + @Data + public static class ConflictingJoinTypeAliasOnField { + + @Join(path = "orders", alias = "order") + @Spec(path = "order.id") + Long orderId; + + @Join(path = "orders", alias = "order", joinType = JoinType.LEFT) + @Spec(path = "order.itemName", value = Like.class) + String itemName; + } + + @Builder + @Data + public static class NullValuedJoinAliasOnField { + + @Join(path = "orders", alias = "o") + @Spec(path = "o.id") + Long orderId; + + @Spec(path = "o.itemName") + String itemName; + } } diff --git a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/SpecJoinContextTest.java b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/SpecJoinContextTest.java index 253f8c2a..1a1f8bfb 100644 --- a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/SpecJoinContextTest.java +++ b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/SpecJoinContextTest.java @@ -20,16 +20,29 @@ */ package tw.com.softleader.data.jpa.spec; +import static java.time.Duration.ofSeconds; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.InstanceOfAssertFactories.MAP; +import static org.awaitility.Awaitility.await; import static org.mockito.Mockito.mock; import static tw.com.softleader.data.jpa.spec.SpecJoinContext.HandleKey.identityHex; +import jakarta.persistence.EntityManager; +import jakarta.persistence.criteria.Root; import java.lang.annotation.Annotation; +import java.lang.ref.WeakReference; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import tw.com.softleader.data.jpa.spec.SpecJoinContext.HandleKey; +import tw.com.softleader.data.jpa.spec.domain.JoinContext.FetchRef; +import tw.com.softleader.data.jpa.spec.usecase.Customer; +@IntegrationTest class SpecJoinContextTest { + @Autowired EntityManager entityManager; + @Test void shouldConvertTargetAndFieldToIdentityHex() throws NoSuchFieldException { var target = new Object(); @@ -100,6 +113,36 @@ void shouldGenerateDifferentKeysForSameTypeFieldsInSameClass() throws NoSuchFiel assertThat(key1.field()).isNotEqualTo(key2.field()); } + @DisplayName("join/fetch 的登錄資料應隨著 Root 一起被回收, 不應無限累積") + @Test + void shouldNotRetainBookkeepingOfCollectedRoot() { + var context = new SpecJoinContext(); + var cb = entityManager.getCriteriaBuilder(); + + var query = cb.createQuery(Customer.class); + Root root = query.from(Customer.class); + context.putIfAbsent(root, "o", root.join("orders")); + context.putIfAbsent(root, "b", new FetchRef(root.fetch("badges"), "badges")); + + assertThat(context.getJoin(root, "o")).isNotNull(); + assertThat(context.getFetch(root, "b")).isNotNull(); + + var collected = new WeakReference<>(root); + // 放掉這次執行所建立的 criteria tree + root = null; + query = null; + + await() + .atMost(ofSeconds(10)) + .untilAsserted( + () -> { + System.gc(); + assertThat(collected.get()).isNull(); + assertThat(context).extracting("joined", MAP).isEmpty(); + assertThat(context).extracting("fetched", MAP).isEmpty(); + }); + } + static class TargetA { private String field; } diff --git a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/yet_another_package/ConstructSimpleSpecificationTest.java b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/yet_another_package/ConstructSimpleSpecificationTest.java index fdde0379..a9b3bdd1 100644 --- a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/yet_another_package/ConstructSimpleSpecificationTest.java +++ b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/yet_another_package/ConstructSimpleSpecificationTest.java @@ -20,15 +20,19 @@ */ package tw.com.softleader.data.jpa.spec.domain.yet_another_package; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.Mockito.mock; import static tw.com.softleader.data.jpa.spec.IntegrationTest.TestApplication.noopContext; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; +import java.util.NoSuchElementException; import lombok.NonNull; import org.junit.jupiter.api.Test; +import tw.com.softleader.data.jpa.spec.SpecContext; import tw.com.softleader.data.jpa.spec.domain.Context; import tw.com.softleader.data.jpa.spec.domain.SimpleSpecification; @@ -86,6 +90,33 @@ void newDefaultClassSpec() { .build()); } + @Test + @SuppressWarnings("unchecked") + void multiSegmentPathWithoutJoinContextShouldExplainTheMapperPipeline() { + // 直接 new 出來的 spec, 其 Context 不會有 SpecMapper 放進去的 JoinContext + var spec = new DottedPathSpec(new SpecContext(), "o.itemName", new Object()); + Root root = mock(Root.class); + + assertThatIllegalStateException() + .isThrownBy(() -> spec.toPredicate(root, null, mock(CriteriaBuilder.class))) + .withMessageContaining("o.itemName") + .withMessageContaining("SpecMapper.toSpec") + .withCauseInstanceOf(NoSuchElementException.class); + } + + public static class DottedPathSpec extends SimpleSpecification { + + DottedPathSpec(@NonNull Context context, @NonNull String path, @NonNull Object value) { + super(context, path, value); + } + + @Override + public Predicate toPredicate(Root root, CriteriaQuery query, CriteriaBuilder criteriaBuilder) { + getPath(root); + return null; + } + } + public static class ProtectedConstructorSpec extends SimpleSpecification { protected ProtectedConstructorSpec(