diff --git a/backend-core-business-impl/pom.xml b/backend-core-business-impl/pom.xml index fed8748..6f090e6 100644 --- a/backend-core-business-impl/pom.xml +++ b/backend-core-business-impl/pom.xml @@ -5,7 +5,7 @@ com.flowingcode.backend-core backend-core - 1.1.1-SNAPSHOT + 1.2.0-SNAPSHOT backend-core-business-impl diff --git a/backend-core-business-spring-impl/pom.xml b/backend-core-business-spring-impl/pom.xml index 7603d91..0978e64 100644 --- a/backend-core-business-spring-impl/pom.xml +++ b/backend-core-business-spring-impl/pom.xml @@ -5,7 +5,7 @@ com.flowingcode.backend-core backend-core - 1.1.1-SNAPSHOT + 1.2.0-SNAPSHOT backend-core-business-spring-impl diff --git a/backend-core-business/pom.xml b/backend-core-business/pom.xml index 29b016a..7d32139 100644 --- a/backend-core-business/pom.xml +++ b/backend-core-business/pom.xml @@ -5,7 +5,7 @@ com.flowingcode.backend-core backend-core - 1.1.1-SNAPSHOT + 1.2.0-SNAPSHOT backend-core-business diff --git a/backend-core-data-impl/pom.xml b/backend-core-data-impl/pom.xml index 846eaa3..1335632 100644 --- a/backend-core-data-impl/pom.xml +++ b/backend-core-data-impl/pom.xml @@ -5,7 +5,7 @@ com.flowingcode.backend-core backend-core - 1.1.1-SNAPSHOT + 1.2.0-SNAPSHOT backend-core-data-impl diff --git a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConstraintTransformerJpaImpl.java b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConstraintTransformerJpaImpl.java index 52c1c8f..1b38387 100644 --- a/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConstraintTransformerJpaImpl.java +++ b/backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConstraintTransformerJpaImpl.java @@ -2,7 +2,7 @@ * #%L * Commons Backend - Data Access Layer Implementations * %% - * Copyright (C) 2020 - 2021 Flowing Code + * Copyright (C) 2020 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,6 +31,7 @@ import com.flowingcode.backendcore.model.constraints.AttributeLikeConstraint; import com.flowingcode.backendcore.model.constraints.AttributeNullConstraint; import com.flowingcode.backendcore.model.constraints.AttributeRelationalConstraint; +import com.flowingcode.backendcore.model.constraints.DisjunctionConstraint; import com.flowingcode.backendcore.model.constraints.NegatedConstraint; import com.flowingcode.backendcore.model.constraints.RelationalConstraint; @@ -44,6 +45,11 @@ import lombok.NonNull; import lombok.RequiredArgsConstructor; +/** + * JPA/Criteria implementation of {@link ConstraintTransformer}. + * + *

Instances are not thread-safe. A new instance must be created for each query. + */ @RequiredArgsConstructor public class ConstraintTransformerJpaImpl extends ConstraintTransformer { @@ -80,10 +86,12 @@ private From join(From root, String[] path) { return from; } + private JoinType currentJoinType = JoinType.INNER; + @SuppressWarnings("rawtypes") private From join(From source, String attributeName) { Optional existingJoin = source.getJoins().stream().filter(join->join.getAttribute().getName().equals(attributeName)).map(join->(Join)join).findFirst(); - return existingJoin.orElseGet(()->source.join(attributeName, JoinType.INNER)); + return existingJoin.orElseGet(()->source.join(attributeName, currentJoinType)); } private static Class boxed(Class type) { @@ -166,4 +174,18 @@ protected Predicate transformNullConstraint(AttributeNullConstraint c) { protected Predicate transformILikeConstraint(AttributeILikeConstraint c) { return criteriaBuilder.like(criteriaBuilder.lower(getExpression(c, String.class)), c.getPattern().toLowerCase()); } + + @Override + protected Predicate transformDisjunctionConstraint(DisjunctionConstraint c) { + JoinType saved = currentJoinType; + currentJoinType = JoinType.LEFT; + try { + Predicate[] predicates = c.getConstraints().stream() + .map(this::apply) + .toArray(Predicate[]::new); + return criteriaBuilder.or(predicates); + } finally { + currentJoinType = saved; + } + } } diff --git a/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/JpaDaoSupportTest.java b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/JpaDaoSupportTest.java index ff8b99f..1d1cb54 100644 --- a/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/JpaDaoSupportTest.java +++ b/backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/JpaDaoSupportTest.java @@ -2,7 +2,7 @@ * #%L * Commons Backend - Data Access Layer Implementations * %% - * Copyright (C) 2020 - 2021 Flowing Code + * Copyright (C) 2020 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -147,6 +147,27 @@ void testFilterByState() { assertEquals(5, count); } + @Test + void testFilterWithOrConstraint() { + // OR of both city ids must match all 10 persons that have a city assigned + PersonFilter pf = new PersonFilter(); + pf.addConstraint( + ConstraintBuilder.of("city", "id").equal(cities.get(0).getId()) + .or(ConstraintBuilder.of("city", "id").equal(cities.get(1).getId()))); + assertEquals(10, dao.count(pf)); + } + + @Test + void testFilterWithOrConstraintPartialMatch() { + // city.id branch matches 5 persons; id branch matches persistedPerson (who has no city). + // LEFT JOIN on city must keep persistedPerson in the result set so the OR can match them. + PersonFilter pf = new PersonFilter(); + pf.addConstraint( + ConstraintBuilder.of("city", "id").equal(cities.get(0).getId()) + .or(ConstraintBuilder.of("id").equal(persistedPerson.getId()))); + assertEquals(6, dao.count(pf)); + } + @Test @Disabled void testDelete() { diff --git a/backend-core-data/pom.xml b/backend-core-data/pom.xml index efda66f..275aedf 100644 --- a/backend-core-data/pom.xml +++ b/backend-core-data/pom.xml @@ -5,7 +5,7 @@ com.flowingcode.backend-core backend-core - 1.1.1-SNAPSHOT + 1.2.0-SNAPSHOT backend-core-data diff --git a/backend-core-model/pom.xml b/backend-core-model/pom.xml index f60ab9a..9504f7b 100644 --- a/backend-core-model/pom.xml +++ b/backend-core-model/pom.xml @@ -5,7 +5,7 @@ com.flowingcode.backend-core backend-core - 1.1.1-SNAPSHOT + 1.2.0-SNAPSHOT backend-core-model diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/Constraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/Constraint.java index 337e2d1..5ae1d30 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/Constraint.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/Constraint.java @@ -2,7 +2,7 @@ * #%L * Commons Backend - Model * %% - * Copyright (C) 2020 - 2021 Flowing Code + * Copyright (C) 2020 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ */ package com.flowingcode.backendcore.model; +import com.flowingcode.backendcore.model.constraints.DisjunctionConstraint; import com.flowingcode.backendcore.model.constraints.NegatedConstraint; public interface Constraint { @@ -26,5 +27,24 @@ public interface Constraint { default Constraint not() { return new NegatedConstraint(this); } - + + /** + * Returns a constraint that is satisfied when this constraint or any of the given constraints is + * satisfied (logical OR). + * + * @param first the first additional constraint + * @param rest optional additional constraints + * @return a {@link DisjunctionConstraint} combining this and the given constraints + */ + default Constraint or(Constraint first, Constraint... rest) { + return DisjunctionConstraint.of(this, prepend(first, rest)); + } + + private static Constraint[] prepend(Constraint first, Constraint[] rest) { + Constraint[] result = new Constraint[1 + rest.length]; + result[0] = first; + System.arraycopy(rest, 0, result, 1, rest.length); + return result; + } + } diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformer.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformer.java index 923d086..8c2213f 100644 --- a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformer.java +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformer.java @@ -2,7 +2,7 @@ * #%L * Commons Backend - Model * %% - * Copyright (C) 2020 - 2021 Flowing Code + * Copyright (C) 2020 - 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ import com.flowingcode.backendcore.model.constraints.AttributeLikeConstraint; import com.flowingcode.backendcore.model.constraints.AttributeNullConstraint; import com.flowingcode.backendcore.model.constraints.AttributeRelationalConstraint; +import com.flowingcode.backendcore.model.constraints.DisjunctionConstraint; import com.flowingcode.backendcore.model.constraints.NegatedConstraint; /** @@ -80,6 +81,10 @@ protected T transform(Constraint c) { return transformILikeConstraint((AttributeILikeConstraint) c); } + if (c instanceof DisjunctionConstraint) { + return transformDisjunctionConstraint((DisjunctionConstraint) c); + } + return null; } @@ -125,4 +130,10 @@ protected T transformNullConstraint(AttributeNullConstraint c) { protected T transformILikeConstraint(AttributeILikeConstraint c) { return null; } + + /** Return an implementation-specific representation of a {@code DisjunctionConstraint} constraint. + * @return an implementation-specific representation of the constraint, or {@code null} if it cannot be transformed.*/ + protected T transformDisjunctionConstraint(DisjunctionConstraint c) { + return null; + } } diff --git a/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java new file mode 100644 index 0000000..7cc1859 --- /dev/null +++ b/backend-core-model/src/main/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraint.java @@ -0,0 +1,57 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed 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. + * #L% + */ +package com.flowingcode.backendcore.model.constraints; + +import com.flowingcode.backendcore.model.Constraint; +import java.util.List; +import java.util.Objects; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; + +/** A constraint that is satisfied when any of its member constraints is satisfied (logical OR). */ +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public final class DisjunctionConstraint implements Constraint { + + @NonNull List constraints; + + public static DisjunctionConstraint of(Constraint first, Constraint... rest) { + List list = new java.util.ArrayList<>(); + Objects.requireNonNull(rest, "constraints must not be null"); + add(list, Objects.requireNonNull(first, "constraint must not be null")); + for (Constraint c : rest) { + add(list, Objects.requireNonNull(c, "constraint must not be null")); + } + return new DisjunctionConstraint(List.copyOf(list)); + } + + private static void add(List list, Constraint c) { + if (c instanceof DisjunctionConstraint) { + list.addAll(((DisjunctionConstraint) c).constraints); + } else { + list.add(c); + } + } + +} diff --git a/backend-core-model/src/test/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraintTest.java b/backend-core-model/src/test/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraintTest.java new file mode 100644 index 0000000..bc6badc --- /dev/null +++ b/backend-core-model/src/test/java/com/flowingcode/backendcore/model/constraints/DisjunctionConstraintTest.java @@ -0,0 +1,77 @@ +/*- + * #%L + * Commons Backend - Model + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed 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. + * #L% + */ +package com.flowingcode.backendcore.model.constraints; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.flowingcode.backendcore.model.Constraint; +import com.flowingcode.backendcore.model.ConstraintBuilder; +import java.util.List; +import org.junit.jupiter.api.Test; + +class DisjunctionConstraintTest { + + private static final Constraint A = ConstraintBuilder.of("a").equal(1); + private static final Constraint B = ConstraintBuilder.of("b").equal(2); + private static final Constraint C = ConstraintBuilder.of("c").equal(3); + + @Test + void testOfProducesCorrectMembers() { + DisjunctionConstraint d = DisjunctionConstraint.of(A, B, C); + assertEquals(List.of(A, B, C), d.getConstraints()); + } + + @Test + void testChainedOrFlattens() { + // a.or(b).or(c) must produce OR(a, b, c), not OR(OR(a, b), c) + Constraint chained = A.or(B).or(C); + DisjunctionConstraint d = (DisjunctionConstraint) chained; + assertEquals(3, d.getConstraints().size()); + assertSame(A, d.getConstraints().get(0)); + assertSame(B, d.getConstraints().get(1)); + assertSame(C, d.getConstraints().get(2)); + } + + @Test + void testOrWithExistingDisjunctionInRestFlattens() { + // DisjunctionConstraint passed as a rest element is also flattened + DisjunctionConstraint ab = DisjunctionConstraint.of(A, B); + DisjunctionConstraint d = DisjunctionConstraint.of(C, ab); + assertEquals(List.of(C, A, B), d.getConstraints()); + } + + @Test + void testNullFirstThrows() { + assertThrows(NullPointerException.class, () -> DisjunctionConstraint.of(null, B)); + } + + @Test + void testNullRestArrayThrows() { + assertThrows(NullPointerException.class, () -> DisjunctionConstraint.of(A, (Constraint[]) null)); + } + + @Test + void testNullElementInRestThrows() { + assertThrows(NullPointerException.class, () -> DisjunctionConstraint.of(A, B, null)); + } + +} diff --git a/pom.xml b/pom.xml index 3c7a4f3..64dd272 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ com.flowingcode.backend-core backend-core pom - 1.1.1-SNAPSHOT + 1.2.0-SNAPSHOT Backend Core Common utilities for backend enterprise application development https://www.flowingcode.com/en/open-source/