From 089d6887dd8a94e4f57921bd314e7dc064125d30 Mon Sep 17 00:00:00 2001 From: Shihyu Ho Date: Mon, 20 Jul 2026 17:11:21 +0800 Subject: [PATCH] fix(mapper): honor first-position @Or/@And in compound composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CompoundSpecification folded its specs with a seedless reduce, so the first-declared spec became the accumulator and its @Or/@And wrapper was never inspected. The same annotated criteria class therefore produced opposite SQL depending on field declaration order. The fold now stably sorts elements that override the compound's default operator to the end before reducing, so composition is permutation invariant — any declaration order of the same annotated fields yields the same predicate. Previously-ignored first-position wrappers now take effect, so affected consumers' result sets change. Also narrows the specs field/constructor from Collection to List so the ordered fold is an explicit contract, and rejects a field carrying both @And and @Or with a descriptive IllegalArgumentException instead of silently resolving to And. Co-authored-by: Claude Opus 4.8 (1M context) --- .../jpa/spec/SimpleSpecificationResolver.java | 13 +- .../spec/domain/CompoundSpecification.java | 17 +- .../data/jpa/spec/domain/Conjunction.java | 11 +- .../data/jpa/spec/domain/Disjunction.java | 11 +- .../spec/SimpleSpecificationResolverTest.java | 259 +++++++++++++++++- .../data/jpa/spec/domain/ConjunctionTest.java | 137 +++++++++ .../data/jpa/spec/domain/DisjunctionTest.java | 137 +++++++++ 7 files changed, 568 insertions(+), 17 deletions(-) create mode 100644 mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/ConjunctionTest.java create mode 100644 mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/DisjunctionTest.java diff --git a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/SimpleSpecificationResolver.java b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/SimpleSpecificationResolver.java index f6cdcb0e..c84aa3cf 100644 --- a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/SimpleSpecificationResolver.java +++ b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/SimpleSpecificationResolver.java @@ -85,10 +85,19 @@ private Specification buildSpecification( if (def.not()) { spec = new Not<>(spec); } - if (databind.getField().isAnnotationPresent(And.class)) { + var and = databind.getField().isAnnotationPresent(And.class); + var or = databind.getField().isAnnotationPresent(Or.class); + if (and && or) { + throw new IllegalArgumentException( + "@And and @Or are mutually exclusive, but both are present on " + + databind.getTarget().getClass().getName() + + "." + + databind.getField().getName()); + } + if (and) { return new tw.com.softleader.data.jpa.spec.domain.And<>(spec); } - if (databind.getField().isAnnotationPresent(Or.class)) { + if (or) { return new tw.com.softleader.data.jpa.spec.domain.Or<>(spec); } return spec; diff --git a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/CompoundSpecification.java b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/CompoundSpecification.java index 398f7c3f..dd65befc 100644 --- a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/CompoundSpecification.java +++ b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/CompoundSpecification.java @@ -20,11 +20,13 @@ */ package tw.com.softleader.data.jpa.spec.domain; +import static java.util.Comparator.comparing; + import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; -import java.util.Collection; +import java.util.List; import java.util.StringJoiner; import lombok.NonNull; import lombok.RequiredArgsConstructor; @@ -36,17 +38,28 @@ @RequiredArgsConstructor abstract class CompoundSpecification implements Specification { - @NonNull protected final transient Collection> specs; + @NonNull protected final transient List> specs; + /** + * Fold 的結果會相依於 element 的順序, 因此在 Fold 前會先把所有覆寫了預設運算子的 element 穩定排序到最後, 如此一來第一個 element + * 就必定是使用預設運算子的, 也就不會有 Wrapper 被忽略的問題; 換句話說, 無論欄位的宣告順序為何, 都會得到相同的組合結果 + */ @Override public Predicate toPredicate( @NonNull Root root, CriteriaQuery query, @NonNull CriteriaBuilder builder) { return specs.stream() + .sorted(comparing(this::overridesOperator)) .reduce(this::combine) .map(spec -> spec.toPredicate(root, query, builder)) .orElse(null); } + /** + * @param element 要檢查的元素 + * @return 該元素是否覆寫了本 Compound 的預設運算子 + */ + protected abstract boolean overridesOperator(Specification element); + /** * @param result 到目前 Combine 的結果 * @param element 下一個元素 diff --git a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/Conjunction.java b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/Conjunction.java index 5388dd9f..15776de3 100644 --- a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/Conjunction.java +++ b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/Conjunction.java @@ -20,7 +20,7 @@ */ package tw.com.softleader.data.jpa.spec.domain; -import java.util.Collection; +import java.util.List; import lombok.NonNull; import org.springframework.data.jpa.domain.Specification; @@ -29,13 +29,18 @@ */ public class Conjunction extends CompoundSpecification { - public Conjunction(@NonNull Collection> specs) { + public Conjunction(@NonNull List> specs) { super(specs); } + @Override + protected boolean overridesOperator(Specification element) { + return element instanceof Or; + } + @Override protected Specification combine(Specification result, Specification element) { - if (element instanceof Or) { + if (overridesOperator(element)) { return result.or(element); } return result.and(element); diff --git a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/Disjunction.java b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/Disjunction.java index 5b9f7190..2db4e4f3 100644 --- a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/Disjunction.java +++ b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/Disjunction.java @@ -20,7 +20,7 @@ */ package tw.com.softleader.data.jpa.spec.domain; -import java.util.Collection; +import java.util.List; import lombok.NonNull; import org.springframework.data.jpa.domain.Specification; @@ -29,13 +29,18 @@ */ public class Disjunction extends CompoundSpecification { - public Disjunction(@NonNull Collection> specs) { + public Disjunction(@NonNull List> specs) { super(specs); } + @Override + protected boolean overridesOperator(Specification element) { + return element instanceof And; + } + @Override protected Specification combine(Specification result, Specification element) { - if (element instanceof And) { + if (overridesOperator(element)) { return result.and(element); } return result.or(element); diff --git a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/SimpleSpecificationResolverTest.java b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/SimpleSpecificationResolverTest.java index f6b67fa4..1aeac1a8 100644 --- a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/SimpleSpecificationResolverTest.java +++ b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/SimpleSpecificationResolverTest.java @@ -21,6 +21,7 @@ package tw.com.softleader.data.jpa.spec; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.InstanceOfAssertFactories.LIST; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -248,12 +249,13 @@ void forceOr2() { .gender(Gender.MALE) .birthday(LocalDate.now().plusDays(1)) .build()); - repository.save( - Customer.builder() - .name("mary") - .gender(Gender.FEMALE) - .birthday(LocalDate.now().minusDays(1)) - .build()); + var mary = + repository.save( + Customer.builder() + .name("mary") + .gender(Gender.FEMALE) + .birthday(LocalDate.now().minusDays(1)) + .build()); var criteria = ForceOr2.builder() @@ -278,12 +280,101 @@ void forceOr2() { .isInstanceOf(After.class); depth1.element(2).isInstanceOf(Equals.class); var actual = repository.findAll(spec); - assertThat(actual).hasSize(2).contains(matt, bob); + assertThat(actual).hasSize(3).contains(matt, bob, mary); verify(simpleResolver, times(numberOfLocalField(ForceOr2.class))) .buildSpecification(any(Context.class), any(Databind.class)); } + @DisplayName("Force Or 3 - @Or 宣告在第一順位") + @Test + void forceOr3() { + var matt = + repository.save( + Customer.builder().name("matt").gender(Gender.MALE).birthday(LocalDate.now()).build()); + var bob = + repository.save( + Customer.builder() + .name("bob") + .gender(Gender.MALE) + .birthday(LocalDate.now().plusDays(1)) + .build()); + var mary = + repository.save( + Customer.builder() + .name("mary") + .gender(Gender.FEMALE) + .birthday(LocalDate.now().minusDays(1)) + .build()); + + var criteria = + ForceOr3.builder() + .name(bob.getName()) + .gender(bob.getGender()) + .birthday(LocalDate.now()) + .build(); + var spec = mapper.toSpec(criteria, Customer.class); + var depth1 = + assertThat(spec) + .isNotNull() + .isInstanceOf(Conjunction.class) + .extracting("specs", LIST) + .hasSize(3); + depth1 + .first() + .isInstanceOf(tw.com.softleader.data.jpa.spec.domain.Or.class) + .extracting("spec") + .isInstanceOf(Not.class) + .extracting("spec") + .isInstanceOf(After.class); + depth1.element(1).isInstanceOf(Equals.class); + depth1.element(2).isInstanceOf(Equals.class); + var actual = repository.findAll(spec); + assertThat(actual).hasSize(3).contains(matt, bob, mary); + + verify(simpleResolver, times(numberOfLocalField(ForceOr3.class))) + .buildSpecification(any(Context.class), any(Databind.class)); + } + + @DisplayName("@Or 無論宣告在哪個順位都得到相同的結果") + @Test + void forceOrIsPermutationInvariant() { + repository.save( + Customer.builder().name("matt").gender(Gender.MALE).birthday(LocalDate.now()).build()); + repository.save( + Customer.builder() + .name("bob") + .gender(Gender.MALE) + .birthday(LocalDate.now().plusDays(1)) + .build()); + repository.save( + Customer.builder() + .name("mary") + .gender(Gender.FEMALE) + .birthday(LocalDate.now().minusDays(1)) + .build()); + + var birthday = LocalDate.now(); + var expected = + repository.findAll( + mapper.toSpec( + ForceOr.builder().name("bob").gender(Gender.MALE).birthday(birthday).build(), + Customer.class)); + assertThat(expected).isNotEmpty(); + assertThat( + repository.findAll( + mapper.toSpec( + ForceOr2.builder().name("bob").gender(Gender.MALE).birthday(birthday).build(), + Customer.class))) + .containsExactlyInAnyOrderElementsOf(expected); + assertThat( + repository.findAll( + mapper.toSpec( + ForceOr3.builder().name("bob").gender(Gender.MALE).birthday(birthday).build(), + Customer.class))) + .containsExactlyInAnyOrderElementsOf(expected); + } + @DisplayName("Force And") @Test void forceAnd() { @@ -333,6 +424,112 @@ void forceAnd() { .buildSpecification(any(Context.class), any(Databind.class)); } + @DisplayName("Force And 2 - @And 宣告在第一順位") + @Test + void forceAnd2() { + var matt = + repository.save( + Customer.builder().name("matt").gender(Gender.MALE).birthday(LocalDate.now()).build()); + repository.save( + Customer.builder() + .name("bob") + .gender(Gender.MALE) + .birthday(LocalDate.now().plusDays(1)) + .build()); + var mary = + repository.save( + Customer.builder() + .name("mary") + .gender(Gender.FEMALE) + .birthday(LocalDate.now().minusDays(1)) + .build()); + + var criteria = + ForceAnd2.builder() + .name(matt.getName()) + .gender(mary.getGender()) + .birthday(LocalDate.now()) + .build(); + var spec = mapper.toSpec(criteria, Customer.class); + var depth1 = + assertThat(spec) + .isNotNull() + .isInstanceOf(Disjunction.class) + .extracting("specs", LIST) + .hasSize(3); + depth1 + .first() + .isInstanceOf(tw.com.softleader.data.jpa.spec.domain.And.class) + .extracting("spec") + .isInstanceOf(Not.class) + .extracting("spec") + .isInstanceOf(After.class); + depth1.element(1).isInstanceOf(Equals.class); + depth1.element(2).isInstanceOf(Equals.class); + var actual = repository.findAll(spec); + assertThat(actual).hasSize(2).contains(matt, mary); + + verify(simpleResolver, times(numberOfLocalField(ForceAnd2.class))) + .buildSpecification(any(Context.class), any(Databind.class)); + } + + @DisplayName("@And 無論宣告在哪個順位都得到相同的結果") + @Test + void forceAndIsPermutationInvariant() { + repository.save( + Customer.builder().name("matt").gender(Gender.MALE).birthday(LocalDate.now()).build()); + repository.save( + Customer.builder() + .name("bob") + .gender(Gender.MALE) + .birthday(LocalDate.now().plusDays(1)) + .build()); + repository.save( + Customer.builder() + .name("mary") + .gender(Gender.FEMALE) + .birthday(LocalDate.now().minusDays(1)) + .build()); + + var birthday = LocalDate.now(); + var expected = + repository.findAll( + mapper.toSpec( + ForceAnd.builder().name("matt").gender(Gender.FEMALE).birthday(birthday).build(), + Customer.class)); + assertThat(expected).isNotEmpty(); + assertThat( + repository.findAll( + mapper.toSpec( + ForceAnd2.builder() + .name("matt") + .gender(Gender.FEMALE) + .birthday(birthday) + .build(), + Customer.class))) + .containsExactlyInAnyOrderElementsOf(expected); + assertThat( + repository.findAll( + mapper.toSpec( + ForceAnd3.builder() + .name("matt") + .gender(Gender.FEMALE) + .birthday(birthday) + .build(), + Customer.class))) + .containsExactlyInAnyOrderElementsOf(expected); + } + + @DisplayName("同一個欄位同時標註 @And 及 @Or 時拋出例外") + @Test + void andOrAreMutuallyExclusive() { + var criteria = AndOrConflict.builder().name("matt").build(); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> mapper.toSpec(criteria, Customer.class)) + .withMessageContaining("@And and @Or are mutually exclusive") + .withMessageContaining(AndOrConflict.class.getName() + ".name"); + } + int numberOfLocalField(@NonNull Class clazz) { var i = new AtomicInteger(); doWithLocalFields(clazz, f -> i.getAndIncrement()); @@ -446,6 +643,19 @@ public static class ForceOr2 { @Spec Gender gender; } + @Builder + @Data + public static class ForceOr3 { + + @Or + @Spec(value = After.class, not = true) + LocalDate birthday; + + @Spec String name; + + @Spec Gender gender; + } + @Or @Builder @Data @@ -460,6 +670,41 @@ public static class ForceAnd { LocalDate birthday; } + @Or + @Builder + @Data + public static class ForceAnd2 { + + @And + @Spec(value = After.class, not = true) + LocalDate birthday; + + @Spec String name; + + @Spec Gender gender; + } + + @Or + @Builder + @Data + public static class ForceAnd3 { + + @Spec String name; + + @And + @Spec(value = After.class, not = true) + LocalDate birthday; + + @Spec Gender gender; + } + + @Builder + @Data + public static class AndOrConflict { + + @And @Or @Spec String name; + } + @Builder @Data public static class SkipEmptyText { diff --git a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/ConjunctionTest.java b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/ConjunctionTest.java new file mode 100644 index 00000000..f0ceb266 --- /dev/null +++ b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/ConjunctionTest.java @@ -0,0 +1,137 @@ +/* + * Copyright © 2022 SoftLeader + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package tw.com.softleader.data.jpa.spec.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static tw.com.softleader.data.jpa.spec.IntegrationTest.TestApplication.noopContext; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.jpa.domain.Specification; +import tw.com.softleader.data.jpa.spec.IntegrationTest; +import tw.com.softleader.data.jpa.spec.usecase.Customer; +import tw.com.softleader.data.jpa.spec.usecase.CustomerRepository; +import tw.com.softleader.data.jpa.spec.usecase.Gender; + +@IntegrationTest +class ConjunctionTest { + + @Autowired CustomerRepository repository; + + Customer matt; + Customer bob; + Customer mary; + + Specification nameIsBob; + Specification genderIsFemale; + Specification nameIsMary; + + @BeforeEach + void setup() { + matt = repository.save(Customer.builder().name("matt").gender(Gender.MALE).build()); + bob = repository.save(Customer.builder().name("bob").gender(Gender.MALE).build()); + mary = repository.save(Customer.builder().name("mary").gender(Gender.FEMALE).build()); + + nameIsBob = new Equals<>(noopContext(), "name", "bob"); + genderIsFemale = new Equals<>(noopContext(), "gender", Gender.FEMALE); + nameIsMary = new Equals<>(noopContext(), "name", "mary"); + } + + @DisplayName("combine 沒有 Wrapper 的元素時使用 And") + @Test + void combineWithoutWrapper() { + var conjunction = new Conjunction(List.of()); + var combined = conjunction.combine(genderIsFemale, nameIsBob); + assertThat(repository.findAll(combined)).isEmpty(); + } + + @DisplayName("combine 有 Or Wrapper 的元素時使用 Or") + @Test + void combineWithOrWrapper() { + var conjunction = new Conjunction(List.of()); + var combined = conjunction.combine(genderIsFemale, new Or<>(nameIsBob)); + assertThat(repository.findAll(combined)).containsExactlyInAnyOrder(bob, mary); + } + + @DisplayName("overridesOperator 只認得 Or") + @Test + void overridesOperator() { + var conjunction = new Conjunction(List.of()); + assertThat(conjunction.overridesOperator(new Or<>(nameIsBob))).isTrue(); + assertThat(conjunction.overridesOperator(new And<>(nameIsBob))).isFalse(); + assertThat(conjunction.overridesOperator(nameIsBob)).isFalse(); + } + + @DisplayName("第一順位的 Or 不會被忽略") + @Test + void orOnFirstPositionIsNotIgnored() { + var spec = new Conjunction<>(List.of(new Or<>(nameIsBob), genderIsFemale)); + assertThat(repository.findAll(spec)).containsExactlyInAnyOrder(bob, mary); + } + + @DisplayName("Or 在任何順位都得到相同的結果") + @Test + void permutationInvariance() { + var expected = List.of(bob, mary); + assertThat(repository.findAll(new Conjunction<>(List.of(new Or<>(nameIsBob), genderIsFemale)))) + .containsExactlyInAnyOrderElementsOf(expected); + assertThat(repository.findAll(new Conjunction<>(List.of(genderIsFemale, new Or<>(nameIsBob))))) + .containsExactlyInAnyOrderElementsOf(expected); + } + + @DisplayName("三個元素的所有排列都得到相同的結果") + @Test + void permutationInvarianceOfThreeElements() { + Specification or = new Or<>(nameIsBob); + var permutations = + List.of( + List.of(or, genderIsFemale, nameIsMary), + List.of(or, nameIsMary, genderIsFemale), + List.of(genderIsFemale, or, nameIsMary), + List.of(genderIsFemale, nameIsMary, or), + List.of(nameIsMary, or, genderIsFemale), + List.of(nameIsMary, genderIsFemale, or)); + // (gender = FEMALE and name = 'mary') or name = 'bob' + var expected = List.of(bob, mary); + permutations.forEach( + specs -> + assertThat(repository.findAll(new Conjunction<>(specs))) + .as("specs=%s", specs) + .containsExactlyInAnyOrderElementsOf(expected)); + } + + @DisplayName("全部都是 Or 時依然全部以 Or 結合") + @Test + void allElementsAreOr() { + var spec = new Conjunction<>(List.of(new Or<>(nameIsBob), new Or<>(nameIsMary))); + assertThat(repository.findAll(spec)).containsExactlyInAnyOrder(bob, mary); + } + + @DisplayName("沒有任何元素時不產生 Predicate") + @Test + void noElement() { + assertThat(repository.findAll(new Conjunction(List.of()))) + .containsExactlyInAnyOrder(matt, bob, mary); + } +} diff --git a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/DisjunctionTest.java b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/DisjunctionTest.java new file mode 100644 index 00000000..16cb9a5c --- /dev/null +++ b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/DisjunctionTest.java @@ -0,0 +1,137 @@ +/* + * Copyright © 2022 SoftLeader + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package tw.com.softleader.data.jpa.spec.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static tw.com.softleader.data.jpa.spec.IntegrationTest.TestApplication.noopContext; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.jpa.domain.Specification; +import tw.com.softleader.data.jpa.spec.IntegrationTest; +import tw.com.softleader.data.jpa.spec.usecase.Customer; +import tw.com.softleader.data.jpa.spec.usecase.CustomerRepository; +import tw.com.softleader.data.jpa.spec.usecase.Gender; + +@IntegrationTest +class DisjunctionTest { + + @Autowired CustomerRepository repository; + + Customer matt; + Customer bob; + Customer mary; + + Specification nameIsBob; + Specification genderIsMale; + Specification nameIsMary; + + @BeforeEach + void setup() { + matt = repository.save(Customer.builder().name("matt").gender(Gender.MALE).build()); + bob = repository.save(Customer.builder().name("bob").gender(Gender.MALE).build()); + mary = repository.save(Customer.builder().name("mary").gender(Gender.FEMALE).build()); + + nameIsBob = new Equals<>(noopContext(), "name", "bob"); + genderIsMale = new Equals<>(noopContext(), "gender", Gender.MALE); + nameIsMary = new Equals<>(noopContext(), "name", "mary"); + } + + @DisplayName("combine 沒有 Wrapper 的元素時使用 Or") + @Test + void combineWithoutWrapper() { + var disjunction = new Disjunction(List.of()); + var combined = disjunction.combine(genderIsMale, nameIsMary); + assertThat(repository.findAll(combined)).containsExactlyInAnyOrder(matt, bob, mary); + } + + @DisplayName("combine 有 And Wrapper 的元素時使用 And") + @Test + void combineWithAndWrapper() { + var disjunction = new Disjunction(List.of()); + var combined = disjunction.combine(genderIsMale, new And<>(nameIsBob)); + assertThat(repository.findAll(combined)).containsExactly(bob); + } + + @DisplayName("overridesOperator 只認得 And") + @Test + void overridesOperator() { + var disjunction = new Disjunction(List.of()); + assertThat(disjunction.overridesOperator(new And<>(nameIsBob))).isTrue(); + assertThat(disjunction.overridesOperator(new Or<>(nameIsBob))).isFalse(); + assertThat(disjunction.overridesOperator(nameIsBob)).isFalse(); + } + + @DisplayName("第一順位的 And 不會被忽略") + @Test + void andOnFirstPositionIsNotIgnored() { + var spec = new Disjunction<>(List.of(new And<>(genderIsMale), nameIsBob)); + assertThat(repository.findAll(spec)).containsExactly(bob); + } + + @DisplayName("And 在任何順位都得到相同的結果") + @Test + void permutationInvariance() { + var expected = List.of(bob); + assertThat(repository.findAll(new Disjunction<>(List.of(new And<>(genderIsMale), nameIsBob)))) + .containsExactlyInAnyOrderElementsOf(expected); + assertThat(repository.findAll(new Disjunction<>(List.of(nameIsBob, new And<>(genderIsMale))))) + .containsExactlyInAnyOrderElementsOf(expected); + } + + @DisplayName("三個元素的所有排列都得到相同的結果") + @Test + void permutationInvarianceOfThreeElements() { + Specification and = new And<>(genderIsMale); + var permutations = + List.of( + List.of(and, nameIsBob, nameIsMary), + List.of(and, nameIsMary, nameIsBob), + List.of(nameIsBob, and, nameIsMary), + List.of(nameIsBob, nameIsMary, and), + List.of(nameIsMary, and, nameIsBob), + List.of(nameIsMary, nameIsBob, and)); + // (name = 'bob' or name = 'mary') and gender = MALE + var expected = List.of(bob); + permutations.forEach( + specs -> + assertThat(repository.findAll(new Disjunction<>(specs))) + .as("specs=%s", specs) + .containsExactlyInAnyOrderElementsOf(expected)); + } + + @DisplayName("全部都是 And 時依然全部以 And 結合") + @Test + void allElementsAreAnd() { + var spec = new Disjunction<>(List.of(new And<>(genderIsMale), new And<>(nameIsBob))); + assertThat(repository.findAll(spec)).containsExactly(bob); + } + + @DisplayName("沒有任何元素時不產生 Predicate") + @Test + void noElement() { + assertThat(repository.findAll(new Disjunction(List.of()))) + .containsExactlyInAnyOrder(matt, bob, mary); + } +}