Skip to content

Commit 90692ec

Browse files
committed
feat: add OR operator to QuerySpec constraints
Close #61
1 parent 2455a70 commit 90692ec

5 files changed

Lines changed: 128 additions & 6 deletions

File tree

backend-core-data-impl/src/main/java/com/flowingcode/backendcore/dao/jpa/ConstraintTransformerJpaImpl.java

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* #%L
33
* Commons Backend - Data Access Layer Implementations
44
* %%
5-
* Copyright (C) 2020 - 2021 Flowing Code
5+
* Copyright (C) 2020 - 2026 Flowing Code
66
* %%
77
* Licensed under the Apache License, Version 2.0 (the "License");
88
* you may not use this file except in compliance with the License.
@@ -31,6 +31,7 @@
3131
import com.flowingcode.backendcore.model.constraints.AttributeLikeConstraint;
3232
import com.flowingcode.backendcore.model.constraints.AttributeNullConstraint;
3333
import com.flowingcode.backendcore.model.constraints.AttributeRelationalConstraint;
34+
import com.flowingcode.backendcore.model.constraints.DisjunctionConstraint;
3435
import com.flowingcode.backendcore.model.constraints.NegatedConstraint;
3536
import com.flowingcode.backendcore.model.constraints.RelationalConstraint;
3637

@@ -44,6 +45,11 @@
4445
import lombok.NonNull;
4546
import lombok.RequiredArgsConstructor;
4647

48+
/**
49+
* JPA/Criteria implementation of {@link ConstraintTransformer}.
50+
*
51+
* <p><b>Instances are not thread-safe.</b> A new instance must be created for each query.
52+
*/
4753
@RequiredArgsConstructor
4854
public class ConstraintTransformerJpaImpl extends ConstraintTransformer<Predicate> {
4955

@@ -80,10 +86,12 @@ private From<?,?> join(From<?,?> root, String[] path) {
8086
return from;
8187
}
8288

89+
private JoinType currentJoinType = JoinType.INNER;
90+
8391
@SuppressWarnings("rawtypes")
8492
private From<?,?> join(From<?,?> source, String attributeName) {
8593
Optional<Join> existingJoin = source.getJoins().stream().filter(join->join.getAttribute().getName().equals(attributeName)).map(join->(Join)join).findFirst();
86-
return existingJoin.orElseGet(()->source.join(attributeName, JoinType.INNER));
94+
return existingJoin.orElseGet(()->source.join(attributeName, currentJoinType));
8795
}
8896

8997
private static Class<?> boxed(Class<?> type) {
@@ -166,4 +174,18 @@ protected Predicate transformNullConstraint(AttributeNullConstraint c) {
166174
protected Predicate transformILikeConstraint(AttributeILikeConstraint c) {
167175
return criteriaBuilder.like(criteriaBuilder.lower(getExpression(c, String.class)), c.getPattern().toLowerCase());
168176
}
177+
178+
@Override
179+
protected Predicate transformDisjunctionConstraint(DisjunctionConstraint c) {
180+
JoinType saved = currentJoinType;
181+
currentJoinType = JoinType.LEFT;
182+
try {
183+
Predicate[] predicates = c.getConstraints().stream()
184+
.map(this::apply)
185+
.toArray(Predicate[]::new);
186+
return criteriaBuilder.or(predicates);
187+
} finally {
188+
currentJoinType = saved;
189+
}
190+
}
169191
}

backend-core-data-impl/src/test/java/com/flowingcode/backendcore/dao/jpa/JpaDaoSupportTest.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* #%L
33
* Commons Backend - Data Access Layer Implementations
44
* %%
5-
* Copyright (C) 2020 - 2021 Flowing Code
5+
* Copyright (C) 2020 - 2026 Flowing Code
66
* %%
77
* Licensed under the Apache License, Version 2.0 (the "License");
88
* you may not use this file except in compliance with the License.
@@ -147,6 +147,27 @@ void testFilterByState() {
147147
assertEquals(5, count);
148148
}
149149

150+
@Test
151+
void testFilterWithOrConstraint() {
152+
// OR of both city ids must match all 10 persons that have a city assigned
153+
PersonFilter pf = new PersonFilter();
154+
pf.addConstraint(
155+
ConstraintBuilder.of("city", "id").equal(cities.get(0).getId())
156+
.or(ConstraintBuilder.of("city", "id").equal(cities.get(1).getId())));
157+
assertEquals(10, dao.count(pf));
158+
}
159+
160+
@Test
161+
void testFilterWithOrConstraintPartialMatch() {
162+
// city.id branch matches 5 persons; id branch matches persistedPerson (who has no city).
163+
// LEFT JOIN on city must keep persistedPerson in the result set so the OR can match them.
164+
PersonFilter pf = new PersonFilter();
165+
pf.addConstraint(
166+
ConstraintBuilder.of("city", "id").equal(cities.get(0).getId())
167+
.or(ConstraintBuilder.of("id").equal(persistedPerson.getId())));
168+
assertEquals(6, dao.count(pf));
169+
}
170+
150171
@Test
151172
@Disabled
152173
void testDelete() {

backend-core-model/src/main/java/com/flowingcode/backendcore/model/Constraint.java

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* #%L
33
* Commons Backend - Model
44
* %%
5-
* Copyright (C) 2020 - 2021 Flowing Code
5+
* Copyright (C) 2020 - 2026 Flowing Code
66
* %%
77
* Licensed under the Apache License, Version 2.0 (the "License");
88
* you may not use this file except in compliance with the License.
@@ -19,12 +19,32 @@
1919
*/
2020
package com.flowingcode.backendcore.model;
2121

22+
import com.flowingcode.backendcore.model.constraints.DisjunctionConstraint;
2223
import com.flowingcode.backendcore.model.constraints.NegatedConstraint;
2324

2425
public interface Constraint {
2526

2627
default Constraint not() {
2728
return new NegatedConstraint(this);
2829
}
29-
30+
31+
/**
32+
* Returns a constraint that is satisfied when this constraint or any of the given constraints is
33+
* satisfied (logical OR).
34+
*
35+
* @param first the first additional constraint
36+
* @param rest optional additional constraints
37+
* @return a {@link DisjunctionConstraint} combining this and the given constraints
38+
*/
39+
default Constraint or(Constraint first, Constraint... rest) {
40+
return DisjunctionConstraint.of(this, prepend(first, rest));
41+
}
42+
43+
private static Constraint[] prepend(Constraint first, Constraint[] rest) {
44+
Constraint[] result = new Constraint[1 + rest.length];
45+
result[0] = first;
46+
System.arraycopy(rest, 0, result, 1, rest.length);
47+
return result;
48+
}
49+
3050
}

backend-core-model/src/main/java/com/flowingcode/backendcore/model/ConstraintTransformer.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* #%L
33
* Commons Backend - Model
44
* %%
5-
* Copyright (C) 2020 - 2021 Flowing Code
5+
* Copyright (C) 2020 - 2026 Flowing Code
66
* %%
77
* Licensed under the Apache License, Version 2.0 (the "License");
88
* you may not use this file except in compliance with the License.
@@ -28,6 +28,7 @@
2828
import com.flowingcode.backendcore.model.constraints.AttributeLikeConstraint;
2929
import com.flowingcode.backendcore.model.constraints.AttributeNullConstraint;
3030
import com.flowingcode.backendcore.model.constraints.AttributeRelationalConstraint;
31+
import com.flowingcode.backendcore.model.constraints.DisjunctionConstraint;
3132
import com.flowingcode.backendcore.model.constraints.NegatedConstraint;
3233

3334
/**
@@ -80,6 +81,10 @@ protected T transform(Constraint c) {
8081
return transformILikeConstraint((AttributeILikeConstraint) c);
8182
}
8283

84+
if (c instanceof DisjunctionConstraint) {
85+
return transformDisjunctionConstraint((DisjunctionConstraint) c);
86+
}
87+
8388
return null;
8489
}
8590

@@ -125,4 +130,10 @@ protected T transformNullConstraint(AttributeNullConstraint c) {
125130
protected T transformILikeConstraint(AttributeILikeConstraint c) {
126131
return null;
127132
}
133+
134+
/** Return an implementation-specific representation of a {@code DisjunctionConstraint} constraint.
135+
* @return an implementation-specific representation of the constraint, or {@code null} if it cannot be transformed.*/
136+
protected T transformDisjunctionConstraint(DisjunctionConstraint c) {
137+
return null;
138+
}
128139
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*-
2+
* #%L
3+
* Commons Backend - Model
4+
* %%
5+
* Copyright (C) 2020 - 2026 Flowing Code
6+
* %%
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
* #L%
19+
*/
20+
package com.flowingcode.backendcore.model.constraints;
21+
22+
import com.flowingcode.backendcore.model.Constraint;
23+
import java.util.List;
24+
import java.util.Objects;
25+
import lombok.AccessLevel;
26+
import lombok.Getter;
27+
import lombok.NonNull;
28+
import lombok.RequiredArgsConstructor;
29+
import lombok.experimental.FieldDefaults;
30+
31+
/** A constraint that is satisfied when any of its member constraints is satisfied (logical OR). */
32+
@Getter
33+
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
34+
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
35+
public final class DisjunctionConstraint implements Constraint {
36+
37+
@NonNull List<Constraint> constraints;
38+
39+
public static DisjunctionConstraint of(Constraint first, Constraint... rest) {
40+
List<Constraint> list = new java.util.ArrayList<>();
41+
list.add(Objects.requireNonNull(first, "constraint must not be null"));
42+
for (Constraint c : rest) {
43+
list.add(Objects.requireNonNull(c, "constraint must not be null"));
44+
}
45+
return new DisjunctionConstraint(List.copyOf(list));
46+
}
47+
48+
}

0 commit comments

Comments
 (0)