1
2 package com.reallifedeveloper.tools.test.database.inmemory;
3
4 import static org.junit.jupiter.api.Assertions.assertEquals;
5 import static org.junit.jupiter.api.Assertions.assertFalse;
6 import static org.junit.jupiter.api.Assertions.assertNotNull;
7 import static org.junit.jupiter.api.Assertions.assertThrows;
8 import static org.junit.jupiter.api.Assertions.assertTrue;
9
10 import java.lang.reflect.Method;
11 import java.math.BigDecimal;
12 import java.time.Clock;
13 import java.time.Instant;
14 import java.util.HashMap;
15 import java.util.HashSet;
16 import java.util.List;
17 import java.util.Locale;
18 import java.util.Map;
19 import java.util.Set;
20
21 import org.junit.jupiter.api.BeforeEach;
22 import org.junit.jupiter.api.Test;
23 import org.springframework.data.jpa.domain.PredicateSpecification;
24
25 import edu.umd.cs.findbugs.annotations.Nullable;
26 import jakarta.persistence.Entity;
27 import jakarta.persistence.EnumType;
28 import jakarta.persistence.Enumerated;
29 import jakarta.persistence.FetchType;
30 import jakarta.persistence.Id;
31 import jakarta.persistence.ManyToOne;
32 import jakarta.persistence.MapKey;
33 import jakarta.persistence.OneToMany;
34 import jakarta.persistence.criteria.CriteriaBuilder;
35 import jakarta.persistence.criteria.Expression;
36 import jakarta.persistence.criteria.Join;
37 import jakarta.persistence.criteria.JoinType;
38 import jakarta.persistence.criteria.MapJoin;
39 import lombok.AllArgsConstructor;
40 import lombok.Getter;
41 import lombok.ToString;
42 import lombok.experimental.Accessors;
43
44 import com.reallifedeveloper.tools.test.database.inmemory.PredicateSpecificationEvaluatorTest.TestEntities.Customer;
45 import com.reallifedeveloper.tools.test.database.inmemory.PredicateSpecificationEvaluatorTest.TestEntities.CustomerGroup;
46 import com.reallifedeveloper.tools.test.database.inmemory.PredicateSpecificationEvaluatorTest.TestEntities.Order;
47 import com.reallifedeveloper.tools.test.database.inmemory.PredicateSpecificationEvaluatorTest.TestEntities.Order.OrderStatus;
48 import com.reallifedeveloper.tools.test.database.inmemory.PredicateSpecificationEvaluatorTest.TestEntities.OrderLine;
49 import com.reallifedeveloper.tools.test.database.inmemory.PredicateSpecificationEvaluatorTest.TestEntities.OrderSpecifications;
50 import com.reallifedeveloper.tools.test.database.inmemory.PredicateSpecificationEvaluatorTest.TestEntities.Product;
51
52 @SuppressWarnings("NullAway")
53 public class PredicateSpecificationEvaluatorTest {
54 private static final Product PRODUCT_BOOK = new Product(1L, "War and Peace", "BOOK", "BOOK-001");
55 private static final Product PRODUCT_TV = new Product(2L, "Samsung TV", "ELECTRONICS", "TV-001");
56 private static final Customer CUSTOMER_1 = new Customer(1L, "Alice", "US", null);
57 private static final Customer CUSTOMER_2 = new Customer(2L, "Bob", "UK", null);
58
59 private static final Clock CLOCK = Clock.systemUTC();
60
61 private final PredicateSpecificationEvaluator<Order> evaluator = new PredicateSpecificationEvaluator<>();
62
63 @BeforeEach
64 public void init() {
65 CUSTOMER_1.clearOrders();
66 CUSTOMER_2.clearOrders();
67 }
68
69 private void sanityCheck(Order... orders) {
70 for (Order order : orders) {
71 assertTrue(evaluator.matches(OrderSpecifications.sanityCheck(), order), "Test data not correctly set up");
72 }
73 }
74
75 @Test
76 void evaluatesSimpleProperties() {
77 Order order = new Order(1L, OrderStatus.PAID, Instant.now(CLOCK), CUSTOMER_1);
78
79 PredicateSpecification<Order> spec = OrderSpecifications.isPaid().and(OrderSpecifications.customerCountryIs("US"));
80
81 assertTrue(evaluator.matches(spec, order));
82 }
83
84 @Test
85 void bothConditionsMustMatchTheSameJoinedRow() {
86 Order order = new Order(1L, OrderStatus.PAID, Instant.now(CLOCK), CUSTOMER_1);
87 order.addOrderLine(new OrderLine(1L, order, PRODUCT_BOOK, 1, new BigDecimal("10")));
88 order.addOrderLine(new OrderLine(2L, order, PRODUCT_TV, 1, new BigDecimal(500)));
89 sanityCheck(order);
90
91 PredicateSpecification<Order> spec = OrderSpecifications
92 .hasLineMatching(OrderSpecifications.lineMatches("BOOK", new BigDecimal("100")));
93
94 assertFalse(evaluator.matches(spec, order));
95 }
96
97 @Test
98 void matchesWhenOneJoinedRowSatisfiesBothConditions() {
99 Order order = new Order(1L, OrderStatus.PAID, Instant.now(CLOCK), CUSTOMER_1);
100 order.addOrderLine(new OrderLine(1L, order, PRODUCT_BOOK, 15, new BigDecimal(10)));
101 order.addOrderLine(new OrderLine(2L, order, PRODUCT_TV, 1, new BigDecimal(500)));
102 sanityCheck(order);
103
104 PredicateSpecification<Order> spec = OrderSpecifications
105 .hasLineMatching(OrderSpecifications.lineMatches("BOOK", new BigDecimal("100")));
106
107 assertTrue(evaluator.matches(spec, order));
108 }
109
110 @Test
111 void separateJoinsAreIndependent() {
112 Order order = new Order(1L, OrderStatus.PAID, Instant.now(CLOCK), CUSTOMER_1);
113 order.addOrderLine(new OrderLine(1L, order, PRODUCT_BOOK, 1, new BigDecimal("10")));
114 order.addOrderLine(new OrderLine(2L, order, PRODUCT_TV, 1, new BigDecimal(500)));
115 sanityCheck(order);
116
117 PredicateSpecification<Order> spec = (root, cb) -> {
118 var lineForCategory = root.join("lines");
119 var lineForPrice = root.join("lines");
120 return cb.and(cb.equal(lineForCategory.join("product").get("category"), "BOOK"),
121 cb.greaterThanOrEqualTo(lineForPrice.get("unitPrice"), new BigDecimal("100")));
122 };
123
124 assertTrue(evaluator.matches(spec, order));
125 }
126
127 @Test
128 public void leftJoin() {
129 PredicateSpecificationEvaluator<Customer> customerEvaluator = new PredicateSpecificationEvaluator<>();
130 Order order = new Order(2L, OrderStatus.NEW, Instant.now(CLOCK), CUSTOMER_2);
131 order.addOrderLine(new OrderLine(2L, order, PRODUCT_TV, 1, new BigDecimal(500)));
132 sanityCheck(order);
133 CUSTOMER_2.addOrder(order);
134
135 PredicateSpecification<Customer> newOrderOrNoOrder = (from, cb) -> {
136 Join<Customer, Order> orders = from.join("orders", JoinType.LEFT);
137 return cb.or(cb.equal(orders.get("status"), OrderStatus.NEW), cb.isNull(orders.get("id")));
138 };
139
140
141 assertEquals(2, customerEvaluator.filter(newOrderOrNoOrder, List.of(CUSTOMER_1, CUSTOMER_2)).size());
142
143 order.setStatus(OrderStatus.PAID);
144
145
146 List<Customer> customers = customerEvaluator.filter(newOrderOrNoOrder, List.of(CUSTOMER_1, CUSTOMER_2));
147 assertEquals(1, customers.size());
148 assertEquals(CUSTOMER_1, customers.getFirst());
149 }
150
151 @Test
152 void filterWithAndWithoutNot() {
153 Order order1 = new Order(1L, OrderStatus.PAID, Instant.now(CLOCK), CUSTOMER_1);
154 order1.addOrderLine(new OrderLine(1L, order1, PRODUCT_BOOK, 1, new BigDecimal("10")));
155 Order order2 = new Order(2L, OrderStatus.NEW, Instant.now(CLOCK), CUSTOMER_2);
156 order2.addOrderLine(new OrderLine(2L, order2, PRODUCT_TV, 1, new BigDecimal(500)));
157 sanityCheck(order1, order2);
158
159 PredicateSpecification<Order> paidSpec = OrderSpecifications.isPaid();
160
161 List<Order> paidOrders = evaluator.filter(paidSpec, List.of(order1, order2));
162 assertEquals(1, paidOrders.size());
163 assertEquals(order1, paidOrders.getFirst());
164
165 PredicateSpecification<Order> unpaidSpec = PredicateSpecification.not(OrderSpecifications.isPaid());
166
167 List<Order> unpaidOrders = evaluator.filter(unpaidSpec, List.of(order1, order2));
168 assertEquals(1, unpaidOrders.size());
169 assertEquals(order2, unpaidOrders.getFirst());
170 }
171
172 @Test
173 public void trueAndFalseAndNotMatchAsExpected() {
174 Order order = new Order(1L, OrderStatus.PAID, Instant.now(CLOCK), CUSTOMER_1);
175 order.addOrderLine(new OrderLine(1L, order, PRODUCT_BOOK, 1, new BigDecimal("10")));
176 order.addOrderLine(new OrderLine(2L, order, PRODUCT_TV, 1, new BigDecimal(500)));
177 sanityCheck(order);
178
179 PredicateSpecification<Order> alwaysTrue = (from, cb) -> cb.conjunction();
180 PredicateSpecification<Order> alwaysFalse = (from, cb) -> cb.disjunction();
181
182 assertTrue(evaluator.matches(alwaysTrue, order));
183 assertFalse(evaluator.matches(PredicateSpecification.not(alwaysTrue), order));
184
185 assertFalse(evaluator.matches(alwaysFalse, order));
186 assertTrue(evaluator.matches(PredicateSpecification.not(alwaysFalse), order));
187 }
188
189 @Test
190 public void comparisonsMatchAsExpected() {
191 PredicateSpecificationEvaluator<OrderLine> lineEvaluator = new PredicateSpecificationEvaluator<>();
192 OrderLine orderLine = new OrderLine(1L, null, PRODUCT_BOOK, 15, new BigDecimal("10"));
193
194 assertFalse(lineEvaluator.matches(OrderSpecifications.lineAmountIsLessThan(new BigDecimal("149")), orderLine));
195 assertFalse(lineEvaluator.matches(OrderSpecifications.lineAmountIsLessThan(new BigDecimal("150")), orderLine));
196 assertTrue(lineEvaluator.matches(OrderSpecifications.lineAmountIsLessThan(new BigDecimal("151")), orderLine));
197
198 assertFalse(lineEvaluator.matches(OrderSpecifications.lineAmountIsLessThanOrEqualTo(new BigDecimal("149")), orderLine));
199 assertTrue(lineEvaluator.matches(OrderSpecifications.lineAmountIsLessThanOrEqualTo(new BigDecimal("150")), orderLine));
200 assertTrue(lineEvaluator.matches(OrderSpecifications.lineAmountIsLessThanOrEqualTo(new BigDecimal("151")), orderLine));
201
202 assertTrue(lineEvaluator.matches(OrderSpecifications.lineAmountIsGreaterThan(new BigDecimal("149")), orderLine));
203 assertFalse(lineEvaluator.matches(OrderSpecifications.lineAmountIsGreaterThan(new BigDecimal("150")), orderLine));
204 assertFalse(lineEvaluator.matches(OrderSpecifications.lineAmountIsGreaterThan(new BigDecimal("151")), orderLine));
205
206 assertTrue(lineEvaluator.matches(OrderSpecifications.lineAmountIsGreaterThanOrEqualTo(new BigDecimal("149")), orderLine));
207 assertTrue(lineEvaluator.matches(OrderSpecifications.lineAmountIsGreaterThanOrEqualTo(new BigDecimal("150")), orderLine));
208 assertFalse(lineEvaluator.matches(OrderSpecifications.lineAmountIsGreaterThanOrEqualTo(new BigDecimal("151")), orderLine));
209 }
210
211 @Test
212 public void inMatchesAsExpected() {
213 Order order1 = new Order(1L, OrderStatus.SHIPPED, Instant.now(CLOCK), CUSTOMER_1);
214 Order order2 = new Order(2L, OrderStatus.NEW, Instant.now(CLOCK), CUSTOMER_2);
215
216 PredicateSpecification<Order> unshippedOrders = (o, cb) -> o.get("status").in(List.of(OrderStatus.NEW, OrderStatus.PAID));
217
218 assertFalse(evaluator.matches(unshippedOrders, order1));
219 assertTrue(evaluator.matches(unshippedOrders, order2));
220 }
221
222 @Test
223 public void nullDoesNotMatchIn() {
224 Order order = new Order(1L, null, Instant.now(CLOCK), CUSTOMER_1);
225
226 PredicateSpecification<Order> unshippedOrders = (o, cb) -> o.get("status").in(List.of(OrderStatus.NEW, OrderStatus.PAID));
227
228 assertFalse(evaluator.matches(unshippedOrders, order));
229 }
230
231 @Test
232 public void inDoesNotFailIfGivenASingleValue() {
233 Order order = new Order(1L, null, Instant.now(CLOCK), CUSTOMER_1);
234
235 PredicateSpecification<Order> spec = (c, cb) -> c.get("id").in(cb.literal(1L));
236
237 assertTrue(evaluator.matches(spec, order));
238 }
239
240 @Test
241 void criteriaBuilderInMatchesAsExpected() {
242 Order order1 = new Order(1L, OrderStatus.SHIPPED, Instant.now(CLOCK), CUSTOMER_1);
243 Order order2 = new Order(2L, OrderStatus.NEW, Instant.now(CLOCK), CUSTOMER_2);
244
245 PredicateSpecification<Order> spec = (root, cb) -> {
246 CriteriaBuilder.In<OrderStatus> in = cb.in(root.get("status"));
247 in.value(OrderStatus.PAID);
248 in.value(OrderStatus.SHIPPED);
249 return in;
250 };
251
252 assertTrue(evaluator.matches(spec, order1));
253 assertFalse(evaluator.matches(spec, order2));
254 }
255
256 @Test
257 void evaluatesMapJoinKey() {
258 Order order1 = new Order(1L, OrderStatus.PAID, Instant.now(CLOCK), CUSTOMER_1);
259 order1.addOrderLine(new OrderLine(1L, order1, PRODUCT_BOOK, 1, new BigDecimal("10")));
260 Order order2 = new Order(2L, OrderStatus.NEW, Instant.now(CLOCK), CUSTOMER_2);
261 order2.addOrderLine(new OrderLine(2L, order2, PRODUCT_TV, 1, new BigDecimal(500)));
262 sanityCheck(order1, order2);
263
264 PredicateSpecification<Order> spec = (root, cb) -> {
265 MapJoin<Order, String, OrderLine> line = root.joinMap("linesBySku");
266 return cb.equal(line.key(), "TV-001");
267 };
268
269 assertFalse(evaluator.matches(spec, order1));
270 assertTrue(evaluator.matches(spec, order2));
271 }
272
273 @Test
274 void evaluatesMapJoinValue() {
275 Order order1 = new Order(1L, OrderStatus.PAID, Instant.now(CLOCK), CUSTOMER_1);
276 order1.addOrderLine(new OrderLine(1L, order1, PRODUCT_BOOK, 1, new BigDecimal("10")));
277 Order order2 = new Order(2L, OrderStatus.NEW, Instant.now(CLOCK), CUSTOMER_2);
278 order2.addOrderLine(new OrderLine(2L, order2, PRODUCT_TV, 1, new BigDecimal(500)));
279 sanityCheck(order1, order2);
280
281 PredicateSpecification<Order> spec = (root, cb) -> {
282 MapJoin<Order, String, OrderLine> line = root.joinMap("linesBySku");
283 return cb.equal(line.value().get("product").get("sku"), line.key());
284 };
285
286 assertTrue(evaluator.matches(spec, order1));
287 assertTrue(evaluator.matches(spec, order2));
288 }
289
290 @Test
291 public void mapJoinCanBeJoined() {
292 Order order1 = new Order(1L, OrderStatus.PAID, Instant.now(CLOCK), CUSTOMER_1);
293 order1.addOrderLine(new OrderLine(1L, order1, PRODUCT_BOOK, 1, new BigDecimal("10")));
294 Order order2 = new Order(2L, OrderStatus.NEW, Instant.now(CLOCK), CUSTOMER_2);
295 order2.addOrderLine(new OrderLine(2L, order2, PRODUCT_TV, 1, new BigDecimal(500)));
296 sanityCheck(order1, order2);
297
298 PredicateSpecification<Order> spec = (o, cb) -> {
299 MapJoin<Order, String, OrderLine> line = o.joinMap("linesBySku");
300 return cb.equal(line.join("order").get("status"), OrderStatus.PAID);
301 };
302
303 assertTrue(evaluator.matches(spec, order1));
304 assertFalse(evaluator.matches(spec, order2));
305 }
306
307 @Test
308 public void comparisonsUsingComparableMatchAsExpected() {
309 PredicateSpecificationEvaluator<Customer> customerEvaluator = new PredicateSpecificationEvaluator<>();
310
311 PredicateSpecification<Customer> nameLessThanB = (c, cb) -> cb.lessThan(c.get("name"), "B");
312
313 assertTrue(customerEvaluator.matches(nameLessThanB, CUSTOMER_1));
314 assertFalse(customerEvaluator.matches(nameLessThanB, CUSTOMER_2));
315 }
316
317 @Test
318 public void orMatchesAsExpected() {
319 PredicateSpecificationEvaluator<OrderLine> lineEvaluator = new PredicateSpecificationEvaluator<>();
320
321
322 PredicateSpecification<OrderLine> spec = OrderSpecifications.lineAmountIsLessThan(new BigDecimal("150"))
323 .or(OrderSpecifications.lineAmountIsGreaterThan(new BigDecimal("150")));
324
325 assertTrue(lineEvaluator.matches(spec, new OrderLine(1L, null, PRODUCT_BOOK, 1, new BigDecimal("149"))));
326 assertFalse(lineEvaluator.matches(spec, new OrderLine(1L, null, PRODUCT_BOOK, 1, new BigDecimal("150"))));
327 assertTrue(lineEvaluator.matches(spec, new OrderLine(1L, null, PRODUCT_BOOK, 1, new BigDecimal("151"))));
328 }
329
330 @Test
331 public void nullChecksMatchAsExpected() {
332 PredicateSpecificationEvaluator<OrderLine> lineEvaluator = new PredicateSpecificationEvaluator<>();
333 OrderLine orderLine1 = new OrderLine(1L, null, PRODUCT_BOOK, 15, new BigDecimal("10"));
334 OrderLine orderLine2 = new OrderLine(null, null, PRODUCT_TV, 1, new BigDecimal(500));
335
336 PredicateSpecification<OrderLine> idIsNull = (line, cb) -> cb.isNull(line.get("id"));
337 PredicateSpecification<OrderLine> idIsNotNull = (line, cb) -> cb.isNotNull(line.get("id"));
338
339 assertFalse(lineEvaluator.matches(idIsNull, orderLine1));
340 assertTrue(lineEvaluator.matches(idIsNull, orderLine2));
341
342 assertTrue(lineEvaluator.matches(idIsNotNull, orderLine1));
343 assertFalse(lineEvaluator.matches(idIsNotNull, orderLine2));
344 }
345
346 @Test
347 public void equalityChecksMatchAsExpected() {
348 PredicateSpecificationEvaluator<OrderLine> lineEvaluator = new PredicateSpecificationEvaluator<>();
349 OrderLine orderLine1 = new OrderLine(1L, null, PRODUCT_BOOK, 15, new BigDecimal("10"));
350 OrderLine orderLine2 = new OrderLine(2L, null, PRODUCT_TV, 1, new BigDecimal(500));
351
352 PredicateSpecification<OrderLine> isIsOne = (line, cb) -> cb.equal(line.get("id"), 1L);
353 PredicateSpecification<OrderLine> isIsNotOne = (line, cb) -> cb.notEqual(line.get("id"), 1L);
354
355 assertTrue(lineEvaluator.matches(isIsOne, orderLine1));
356 assertFalse(lineEvaluator.matches(isIsOne, orderLine2));
357
358 assertFalse(lineEvaluator.matches(isIsNotOne, orderLine1));
359 assertTrue(lineEvaluator.matches(isIsNotOne, orderLine2));
360 }
361
362 @Test
363 public void unrestrictedMatchesEverything() {
364 Order order = new Order(1L, OrderStatus.PAID, Instant.now(CLOCK), CUSTOMER_1);
365
366 assertTrue(evaluator.matches(PredicateSpecification.unrestricted(), order));
367 }
368
369 @Test
370 public void isEmptyMatchesAsExpected() {
371 Order order1 = new Order(1L, OrderStatus.PAID, Instant.now(CLOCK), CUSTOMER_1);
372 order1.addOrderLine(new OrderLine(1L, order1, PRODUCT_BOOK, 1, new BigDecimal("10")));
373 Order order2 = new Order(2L, OrderStatus.NEW, Instant.now(CLOCK), CUSTOMER_2);
374
375 PredicateSpecification<Order> noLines = (o, cb) -> cb.isEmpty(o.get("lines"));
376
377 assertFalse(evaluator.matches(noLines, order1));
378 assertTrue(evaluator.matches(noLines, order2));
379 }
380
381 @Test
382 public void isEmptyFailsIfNotGivenACollection() {
383 Order order = new Order(1L, null, Instant.now(CLOCK), CUSTOMER_1);
384
385 PredicateSpecification<Order> spec = (o, cb) -> cb.isEmpty(o.get("id"));
386
387 Exception e = assertThrows(IllegalArgumentException.class, () -> evaluator.matches(spec, order));
388 assertEquals("isEmpty() requires a Collection, but got java.lang.Long", e.getMessage());
389 }
390
391 @Test
392 public void isEmptyConsidersNullAsUnknownWhichDoesNotMatch() {
393 Order order = new Order(1L, null, Instant.now(CLOCK), CUSTOMER_1);
394
395 PredicateSpecification<Order> spec = (o, cb) -> cb.isEmpty(o.get("status"));
396
397 assertFalse(evaluator.matches(spec, order));
398
399 }
400
401 @Test
402 public void isNotEmptyMatchesAsExpected() {
403 Order order1 = new Order(1L, OrderStatus.PAID, Instant.now(CLOCK), CUSTOMER_1);
404 order1.addOrderLine(new OrderLine(1L, order1, PRODUCT_BOOK, 1, new BigDecimal("10")));
405 Order order2 = new Order(2L, OrderStatus.NEW, Instant.now(CLOCK), CUSTOMER_2);
406
407 PredicateSpecification<Order> hasLines = (o, cb) -> cb.isNotEmpty(o.get("lines"));
408
409 assertTrue(evaluator.matches(hasLines, order1));
410 assertFalse(evaluator.matches(hasLines, order2));
411 }
412
413 @Test
414 public void isNotEmptyFailsIfNotGivenACollection() {
415 Order order = new Order(1L, null, Instant.now(CLOCK), CUSTOMER_1);
416
417 PredicateSpecification<Order> spec = (o, cb) -> cb.isNotEmpty(o.get("id"));
418
419 Exception e = assertThrows(IllegalArgumentException.class, () -> evaluator.matches(spec, order));
420 assertEquals("isNotEmpty() requires a Collection, but got java.lang.Long", e.getMessage());
421 }
422
423 @Test
424 public void isNotEmptyConsidersNullAsUnknownWhichDoesNotMatch() {
425 Order order = new Order(1L, null, Instant.now(CLOCK), CUSTOMER_1);
426
427 PredicateSpecification<Order> spec = (o, cb) -> cb.isNotEmpty(o.get("status"));
428
429 assertFalse(evaluator.matches(spec, order));
430
431 }
432
433 @Test
434 void evaluatesRegisteredFunction() {
435 PredicateSpecificationEvaluator<Order> evaluatorWithFunction = new PredicateSpecificationEvaluator<Order>().registerFunction(
436 "normalize", String.class, String.class, value -> value == null ? null : value.trim().toLowerCase(Locale.ROOT));
437
438 Customer customer = new Customer(1L, "Alice", " US ", null);
439 Order order = new Order(1L, OrderStatus.NEW, Instant.now(CLOCK), customer);
440
441 PredicateSpecification<Order> spec = (root, cb) -> {
442 Join<Order, Customer> c = root.join("customer");
443 Expression<String> country = cb.function("normalize", String.class, c.get("country"));
444 return cb.equal(country, "us");
445 };
446
447 assertTrue(evaluatorWithFunction.matches(spec, order));
448 }
449
450 @Test
451 void failsForUnregisteredFunction() {
452 Order order = new Order(1L, OrderStatus.NEW, Instant.now(CLOCK), CUSTOMER_1);
453 PredicateSpecification<Order> spec = (root, cb) -> cb.equal(cb.function("unknown_function", String.class, root.get("status")),
454 "foo");
455
456 Exception e = assertThrows(UnsupportedOperationException.class, () -> evaluator.matches(spec, order));
457
458 assertEquals("No evaluator function registered for CriteriaBuilder.function(unknown_function, java.lang.String)", e.getMessage());
459 }
460
461 @Test
462 public void functionFailsIfGivenTheWrongNumberOfArguments() {
463 PredicateSpecificationEvaluator<Customer> evaluatorWithFunction = new PredicateSpecificationEvaluator<Customer>()
464 .registerFunction("toLower", String.class, String.class, s -> s.toLowerCase(Locale.ROOT));
465
466 PredicateSpecification<Customer> spec = (c, cb) -> cb.equal(cb.function("toLower", String.class, c.get("name"), cb.literal("foo")),
467 cb.literal("alice"));
468
469 Exception e = assertThrows(IllegalArgumentException.class, () -> evaluatorWithFunction.matches(spec, CUSTOMER_1));
470 assertEquals("Function 'toLower' expected 1 arguments but received 2", e.getMessage());
471 }
472
473 @Test
474 public void functionFailsIfGivenArgumentOfWrongType() {
475 PredicateSpecificationEvaluator<Customer> evaluatorWithFunction = new PredicateSpecificationEvaluator<Customer>()
476 .registerFunction("toLower", String.class, String.class, s -> s.toLowerCase(Locale.ROOT));
477
478 PredicateSpecification<Customer> spec = (c, cb) -> cb.equal(cb.function("toLower", String.class, cb.literal(42)),
479 cb.literal("alice"));
480
481 Exception e = assertThrows(IllegalArgumentException.class, () -> evaluatorWithFunction.matches(spec, CUSTOMER_1));
482 assertEquals("Argument 0 to function 'toLower' was java.lang.Integer, expected java.lang.String", e.getMessage());
483 }
484
485 @Test
486 public void functionFailsIfCalledWithWrongResultType() {
487 PredicateSpecificationEvaluator<Customer> evaluatorWithFunction = new PredicateSpecificationEvaluator<Customer>()
488 .registerFunction("toLower", String.class, String.class, s -> s.toLowerCase(Locale.ROOT));
489
490 PredicateSpecification<Customer> spec = (c, cb) -> cb.equal(cb.function("toLower", Integer.class, cb.literal("foo")),
491 cb.literal("alice"));
492
493 Exception e = assertThrows(IllegalArgumentException.class, () -> evaluatorWithFunction.matches(spec, CUSTOMER_1));
494 assertEquals(
495 "Function 'toLower' was requested with result type java.lang.Integer, but is registered with result type java.lang.String",
496 e.getMessage());
497 }
498
499 @Test
500 public void functionFailsIfReturningWrongResultType() {
501 PredicateSpecificationEvaluator<Customer> evaluatorWithFunction = new PredicateSpecificationEvaluator<Customer>()
502 .registerFunction("foo", String.class, List.of(String.class), s -> 42);
503
504 PredicateSpecification<Customer> spec = (c, cb) -> cb.equal(cb.function("foo", String.class, cb.literal("foo")),
505 cb.literal("alice"));
506
507 Exception e = assertThrows(IllegalArgumentException.class, () -> evaluatorWithFunction.matches(spec, CUSTOMER_1));
508 assertEquals("Function 'foo' returned java.lang.Integer, but CriteriaBuilder.function() declared java.lang.String", e.getMessage());
509 }
510
511 @Test
512 public void functionsCanHandlePrimitiveReturnTypes() {
513 PredicateSpecificationEvaluator<Customer> evaluatorWithFunction = new PredicateSpecificationEvaluator<Customer>()
514 .registerFunction("intFunction", int.class, List.of(long.class), args -> 42)
515 .registerFunction("longFunction", long.class, List.of(float.class), args -> 42L)
516 .registerFunction("floatFunction", float.class, List.of(double.class), args -> 42.0f)
517 .registerFunction("doubleFunction", double.class, List.of(short.class), args -> 42.0)
518 .registerFunction("shortFunction", short.class, List.of(byte.class), args -> (short) 42)
519 .registerFunction("byteFunction", byte.class, List.of(boolean.class), args -> (byte) 42)
520 .registerFunction("booleanFunction", boolean.class, List.of(char.class), args -> true)
521 .registerFunction("charFunction", char.class, List.of(int.class), args -> '*');
522
523 PredicateSpecification<Customer> spec = (from,
524 cb) -> cb.equal(
525 cb.function("intFunction", int.class,
526 cb.function("longFunction", long.class,
527 cb.function("floatFunction", float.class,
528 cb.function("doubleFunction", double.class,
529 cb.function("shortFunction", short.class, cb.function("byteFunction", byte.class,
530 cb.function("booleanFunction", boolean.class,
531 cb.function("charFunction", char.class, cb.literal(0))))))))),
532 cb.literal(42));
533
534 assertTrue(evaluatorWithFunction.matches(spec, CUSTOMER_1));
535 }
536
537 @Test
538 public void propertyAccessUsingFieldsAndNestedGets() {
539 PredicateSpecificationEvaluator<Customer> customerEvaluator = new PredicateSpecificationEvaluator<>();
540 CustomerGroup customerGroup = new CustomerGroup(1L, "My Group");
541 Customer customer = new Customer(1L, "Charlie", "NZ", customerGroup);
542
543
544 PredicateSpecification<Customer> spec = (c, cb) -> cb.equal(c.get("group").get("name"), "My Group");
545
546 assertTrue(customerEvaluator.matches(spec, customer));
547 }
548
549 @Test
550 public void negatedPredicateMatchesAsExpected() {
551 Order order1 = new Order(1L, OrderStatus.PAID, Instant.now(CLOCK), CUSTOMER_1);
552 Order order2 = new Order(2L, OrderStatus.NEW, Instant.now(CLOCK), CUSTOMER_2);
553
554 PredicateSpecification<Order> unpaidSpec = (o, cb) -> cb.equal(o.get("status"), OrderStatus.PAID).not();
555
556 assertFalse(evaluator.matches(unpaidSpec, order1));
557 assertTrue(evaluator.matches(unpaidSpec, order2));
558 }
559
560 @Test
561 public void notConsidersNullAsUnknownWhichDoesNotMatch() {
562 Order order = new Order(1L, null, Instant.now(CLOCK), CUSTOMER_1);
563
564 PredicateSpecification<Order> spec = (o, cb) -> cb.not(cb.equal(o.get("status"), cb.literal(OrderStatus.PAID)));
565
566 assertFalse(evaluator.matches(spec, order));
567 }
568
569 @Test
570 public void usingUnimplementedMethodThrowsUnsupportedOperationException() throws NoSuchMethodException {
571 Order order = new Order(1L, OrderStatus.PAID, Instant.now(CLOCK), CUSTOMER_1);
572 Method unimplementedMethod = Expression.class.getMethod("isNotNull");
573
574 PredicateSpecification<Order> spec = (o, cb) -> cb.isTrue(o.get("status").isNotNull());
575
576 Exception e = assertThrows(UnsupportedOperationException.class, () -> evaluator.matches(spec, order));
577 assertEquals("Unsupported Criteria API operation: " + unimplementedMethod, e.getMessage());
578 }
579
580 @Test
581 public void accessingUnknownPropertyThrowsIllegalArgumentException() {
582 Order order = new Order(1L, OrderStatus.PAID, Instant.now(CLOCK), CUSTOMER_1);
583 PredicateSpecification<Order> spec = (o, cb) -> cb.equal(o.get("no_such_field"), "foo");
584
585 Exception e = assertThrows(IllegalArgumentException.class, () -> evaluator.matches(spec, order));
586 assertEquals("No readable property 'no_such_field' on " + Order.class.getName(), e.getMessage());
587 }
588
589 @Test
590 public void pathImplementsObjectMethods() {
591 PredicateSpecificationEvaluator<Customer> customerEvaluator = new PredicateSpecificationEvaluator<>();
592 PredicateSpecification<Customer> spec = (c, cb) -> {
593 assertNotNull(c.get("name").toString());
594 assertNotNull(c.get("name").hashCode());
595
596
597 return cb.conjunction();
598 };
599 assertTrue(customerEvaluator.matches(spec, CUSTOMER_1));
600 }
601
602 public static class TestEntities {
603
604 @Entity
605 @Getter
606 @Accessors(fluent = true)
607 @AllArgsConstructor
608 @ToString
609 public static class Order {
610
611 public enum OrderStatus {
612
613 NEW,
614
615 PAID,
616
617 SHIPPED
618 }
619
620 @Id
621 private Long id;
622
623 @Enumerated(EnumType.STRING)
624 private OrderStatus status;
625
626 private Instant createdAt;
627
628 @ManyToOne(fetch = FetchType.LAZY)
629 private Customer customer;
630
631 @OneToMany(mappedBy = "order")
632 private final Set<OrderLine> lines = new HashSet<>();
633
634 @OneToMany
635 @MapKey(name = "sku")
636 private final Map<String, OrderLine> linesBySku = new HashMap<>();
637
638 public void setStatus(OrderStatus status) {
639 this.status = status;
640 }
641
642 public void addOrderLine(OrderLine orderLine) {
643 lines.add(orderLine);
644 linesBySku.put(orderLine.product().sku(), orderLine);
645 }
646 }
647
648 @Entity
649 @Getter
650 @Accessors(fluent = false)
651 @AllArgsConstructor
652 @ToString
653 public static class Customer {
654
655 @Id
656 private Long id;
657
658 private String name;
659
660 private String country;
661
662 @ManyToOne(fetch = FetchType.LAZY)
663 @Nullable
664 private CustomerGroup group;
665
666 @OneToMany
667 @ToString.Exclude
668 private final Set<Order> orders = new HashSet<>();
669
670 public void addOrder(Order order) {
671 orders.add(order);
672 }
673
674 public void clearOrders() {
675 orders.clear();
676 }
677 }
678
679 @Entity
680
681
682 @AllArgsConstructor
683 @ToString
684 public static class CustomerGroup {
685
686 @Id
687 private Long id;
688
689 private String name;
690 }
691
692 @Entity
693 @Getter
694 @Accessors(fluent = true)
695 @AllArgsConstructor
696 @ToString
697 public static class OrderLine {
698
699 @Id
700 private Long id;
701
702 @ManyToOne(fetch = FetchType.LAZY)
703 @ToString.Exclude
704 private Order order;
705
706 @ManyToOne(fetch = FetchType.LAZY)
707 private Product product;
708
709 private int quantity;
710
711 private BigDecimal unitPrice;
712 }
713
714 @Entity
715 @Getter
716 @Accessors(fluent = true)
717 @AllArgsConstructor
718 @ToString
719 public static class Product {
720
721 @Id
722 private Long id;
723
724 private String name;
725
726 private String category;
727
728 private String sku;
729 }
730
731 public static class OrderSpecifications {
732
733
734 public static PredicateSpecification<Order> sanityCheck() {
735 return (o, cb) -> {
736 MapJoin<Order, String, OrderLine> line = o.joinMap("linesBySku");
737 return cb.or(cb.isEmpty(o.get("lines")), cb.equal(line.key(), line.get("product").get("sku")));
738 };
739 }
740
741 public static PredicateSpecification<Order> isPaid() {
742 return (order, cb) -> cb.equal(order.get("status"), OrderStatus.PAID);
743 }
744
745 public static PredicateSpecification<Order> customerCountryIs(String country) {
746 return (order, cb) -> {
747 var customer = order.join("customer");
748 return cb.equal(customer.get("country"), country);
749 };
750 }
751
752 public static PredicateSpecification<Order> hasSku(String sku) {
753 return (order, cb) -> {
754 MapJoin<Order, String, OrderLine> lines = order.joinMap("linesBySku");
755 return cb.equal(lines.key(), sku);
756 };
757 }
758
759 public static PredicateSpecification<Order> hasExpensiveSku(String sku, BigDecimal minimumPrice) {
760 return (order, cb) -> {
761 MapJoin<Order, String, OrderLine> lines = order.joinMap("linesBySku");
762 return cb.and(cb.equal(lines.key(), sku), cb.greaterThanOrEqualTo(lines.value().get("unitPrice"), minimumPrice));
763 };
764 }
765
766 public static PredicateSpecification<OrderLine> lineMatches(String category, BigDecimal minimumValue) {
767 return (line, cb) -> cb.and(cb.equal(line.join("product").get("category"), category),
768 cb.greaterThanOrEqualTo(cb.prod(line.get("unitPrice"), line.get("quantity")), minimumValue));
769 }
770
771 public static PredicateSpecification<Order> hasLineMatching(PredicateSpecification<OrderLine> lineSpecification) {
772 return (order, cb) -> {
773 Join<Order, OrderLine> line = order.join("lines");
774 return lineSpecification.toPredicate(line, cb);
775 };
776 }
777
778 public static PredicateSpecification<OrderLine> lineAmountIsLessThan(BigDecimal amount) {
779 return (line, cb) -> cb.lessThan(cb.prod(line.get("unitPrice"), line.get("quantity")), amount);
780 }
781
782 public static PredicateSpecification<OrderLine> lineAmountIsLessThanOrEqualTo(BigDecimal amount) {
783 return (line, cb) -> cb.lessThanOrEqualTo(cb.prod(line.get("unitPrice"), line.get("quantity")), amount);
784 }
785
786 public static PredicateSpecification<OrderLine> lineAmountIsGreaterThan(BigDecimal amount) {
787 return (line, cb) -> cb.greaterThan(cb.prod(line.get("unitPrice"), line.get("quantity")), amount);
788 }
789
790 public static PredicateSpecification<OrderLine> lineAmountIsGreaterThanOrEqualTo(BigDecimal amount) {
791 return (line, cb) -> cb.greaterThanOrEqualTo(cb.prod(line.get("unitPrice"), line.get("quantity")), amount);
792 }
793
794 }
795 }
796 }