View Javadoc
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         // CUSTOMER_1 has no orders, CUSTOMER_2 has an unpaid order.
141         assertEquals(2, customerEvaluator.filter(newOrderOrNoOrder, List.of(CUSTOMER_1, CUSTOMER_2)).size());
142 
143         order.setStatus(OrderStatus.PAID);
144 
145         // Now that CUSTOMER_2's order is paid, only CUSTOMER_1 remains.
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         // GT or LT = NE
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"); // The CustomerGroup class has no getters
541         Customer customer = new Customer(1L, "Charlie", "NZ", customerGroup);
542 
543         // We use "c.get" instead of "c.join" to verify that the nested get("name") call works as epxected.
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             // TODO: The next line causes a loop and stack overflow.
596             // c.get("name").equals(cb.literal("foo"));
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                 /** Order is new. */
613                 NEW,
614                 /** Order has been paid. */
615                 PAID,
616                 /** Order has been shipped. */
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) // We use "getNnn" here in order to test that part of the logic in PredicationSpecificationEvaluator
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         // @Getter // No getters in this class, we want to test field access in PredicateSpecificationEvaluator
681         // @Accessors(fluent = true)
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             // Verifies that Orders have been correctly constructed, with "linesBySku" matching their Product.
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 }