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 f6cdcb0..c84aa3c 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 398f7c3..dd65bef 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 5388dd9..15776de 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 5b9f719..2db4e4f 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 f6b67fa..1aeac1a 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 0000000..f0ceb26 --- /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 0000000..16cb9a5 --- /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); + } +}