Skip to content

Commit 6c2f70b

Browse files
authored
Merge pull request #1624 from BertschiAG/1623-fix-find-nested-element-matching-multiple-conditions
1623 fix find nested element matching multiple conditions
2 parents 2d49a33 + bb968c2 commit 6c2f70b

2 files changed

Lines changed: 176 additions & 15 deletions

File tree

criteria/common/test/org/immutables/criteria/personmodel/AbstractPersonTest.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -599,7 +599,7 @@ public void iterableAny() {
599599
// two pets one with a toy
600600
this.insert(
601601
generator.next().withFullName("Emma").withPets(
602-
ImmutablePet.builder().name("fluffy").type(Pet.PetType.gecko).build(),
602+
ImmutablePet.builder().name("fluffy").type(Pet.PetType.dog).build(),
603603
ImmutablePet.builder().name("oopsy").type(Pet.PetType.panda)
604604
.address(
605605
ImmutableAddress.builder()
@@ -638,6 +638,21 @@ public void iterableAny() {
638638
this.check(this.repository().find(this.person.pets.any().toys.any().type.is(ToyType.robot).and(this.person.pets.any().address.value().zip.is("10154")))).toList(Person::fullName).isOf("Adam");
639639
this.check(this.repository().find(this.person.pets.any().toys.any().type.is(ToyType.ring).or(this.person.pets.any().address.value().zip.endsWith("72")))).toList(Person::fullName).hasContentInAnyOrder("Adam", "Emma", "Christine");
640640
this.check(this.repository().find(this.person.pets.any().toys.any().not(p -> p.type.is(ToyType.ring)))).toList(Person::fullName).hasContentInAnyOrder("Adam", "Emma");
641+
// nested collection with element matching multiple conditions
642+
this.check(this.repository().find(this.person.pets.any().with(pet -> pet.name.is("fluffy").and(pet.type.is(Pet.PetType.gecko))))).toList(Person::fullName).isOf("Adam");
643+
this.check(this.repository().find(this.person.pets.any().with(pet -> pet.name.is("fluffy").and(pet.type.not(type -> type.is(Pet.PetType.gecko)))))).toList(Person::fullName).hasContentInAnyOrder("Emma");
644+
// nested AND/OR combinations - element matching (a AND (b OR c))
645+
// Match pets named "fluffy" that are either gecko OR dog (should match Adam and Emma)
646+
this.check(this.repository().find(this.person.pets.any().with(pet -> pet.name.is("fluffy").and(pet.type.is(Pet.PetType.gecko).or(pet.type.is(Pet.PetType.dog)))))).toList(Person::fullName).hasContentInAnyOrder("Adam", "Emma");
647+
// nested OR/AND combinations - element matching (a OR (b AND c))
648+
// Match pets that are either named "nummy" OR (named "fluffy" AND type gecko)
649+
this.check(this.repository().find(this.person.pets.any().with(pet -> pet.name.is("nummy").or(pet.name.is("fluffy").and(pet.type.is(Pet.PetType.gecko)))))).toList(Person::fullName).hasContentInAnyOrder("Paul", "Adam");
650+
// nested OR/AND combinations - element matching ((a AND b) OR (c AND d))
651+
// Match pets where (name="fluffy" AND type=gecko) OR (name="nummy" AND type=cat)
652+
this.check(this.repository().find(this.person.pets.any().with(pet -> pet.name.is("fluffy").and(pet.type.is(Pet.PetType.gecko)).or(pet.name.is("nummy").and(pet.type.is(Pet.PetType.cat)))))).toList(Person::fullName).hasContentInAnyOrder("Adam", "Paul");
653+
// nested OR/AND combinations with NOT - element matching (a AND NOT(b OR c))
654+
// Match pets named "fluffy" that are NOT (gecko OR panda) - should match Emma (fluffy the dog)
655+
this.check(this.repository().find(this.person.pets.any().with(pet -> pet.name.is("fluffy").and(pet.type.not(t -> t.is(Pet.PetType.gecko).or().is(Pet.PetType.panda)))))).toList(Person::fullName).isOf("Emma");
641656
}
642657

643658
@Test

criteria/mongo/src/org/immutables/criteria/mongo/FindVisitor.java

Lines changed: 160 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -116,18 +116,19 @@ public Bson visit(Call call) {
116116
rightCall = (Call) rightCall.arguments().get(1);
117117
}
118118

119-
Operator operator;
120-
if (rightCall.operator() == Operators.NOT) {
121-
Preconditions.checkArgument(rightCall.arguments().get(0) instanceof Call, "%s is not a call", rightCall.arguments().get(0));
122-
123-
rightCall = (Call) rightCall.arguments().get(0);
124-
operator = negateOperator(rightCall.operator());
125-
} else {
126-
operator = rightCall.operator();
119+
// Handle AND/OR/NOT operators within ANY (for compound conditions on array elements)
120+
if (rightCall.operator() == Operators.AND ||
121+
rightCall.operator() == Operators.OR ||
122+
rightCall.operator() == Operators.NOT) {
123+
final String arrayField = naming.name(path);
124+
Bson condition = buildElemMatchCondition(rightCall);
125+
return Filters.elemMatch(arrayField, condition);
127126
}
128127

128+
// Handle simple comparison operators within ANY
129129
Preconditions.checkArgument(rightCall.arguments().get(0) instanceof Path,"%s is not a path", rightCall.arguments().get(0));
130130

131+
// Reconstruct the full path by combining the array path with the condition path
131132
Path currentPath = Visitors.toPath(rightCall.arguments().get(0));
132133
List<Path> paths = new ArrayList<>();
133134
while (currentPath.parent().isPresent()) {
@@ -139,11 +140,7 @@ public Bson visit(Call call) {
139140
path = Path.combine(path, tmpPath);
140141
}
141142

142-
if (rightCall.operator().arity() == Operator.Arity.UNARY) {
143-
return visit(Expressions.unaryCall(rightCall.operator(), path));
144-
} else {
145-
return binaryCall(Expressions.binaryCall(operator, path, rightCall.arguments().get(1)));
146-
}
143+
return buildCondition(rightCall, rightCall.operator(), path);
147144
}
148145

149146
if (op.arity() == Operator.Arity.BINARY) {
@@ -310,7 +307,156 @@ private static Operator negateOperator(Operator operator) {
310307
throw new UnsupportedOperationException("No negation for " + operator + " defined");
311308
}
312309
}
313-
310+
311+
/**
312+
* Recursively builds a MongoDB filter condition for use inside $elemMatch.
313+
* Handles arbitrarily nested AND/OR/NOT operators by traversing the expression tree.
314+
*
315+
* @param expr the expression to convert (must be a Call)
316+
* @return a Bson filter suitable for use inside Filters.elemMatch()
317+
*/
318+
private Bson buildElemMatchCondition(Expression expr) {
319+
if (!(expr instanceof Call)) {
320+
throw new IllegalArgumentException("Expected Call but got " + expr);
321+
}
322+
323+
Call call = (Call) expr;
324+
Operator op = call.operator();
325+
326+
// Handle AND/OR recursively
327+
if (op == Operators.AND || op == Operators.OR) {
328+
List<Bson> conditions = new ArrayList<>();
329+
330+
for (Expression arg : call.arguments()) {
331+
Preconditions.checkArgument(arg instanceof Call, "Expected Call but got %s", arg);
332+
Call argCall = (Call) arg;
333+
334+
// Check if this argument has nested AND/OR that needs recursive handling
335+
if (argCall.operator() == Operators.AND || argCall.operator() == Operators.OR) {
336+
// Recursively process nested AND/OR
337+
conditions.add(buildElemMatchCondition(arg));
338+
} else {
339+
// Handle NOT and leaf conditions
340+
conditions.add(buildLeafCondition(argCall));
341+
}
342+
}
343+
344+
return op == Operators.AND ? Filters.and(conditions) : Filters.or(conditions);
345+
}
346+
347+
// For top-level NOT or leaf conditions
348+
return buildLeafCondition(call);
349+
}
350+
351+
/**
352+
* Builds a condition that may be wrapped in NOT operator(s).
353+
* <p>
354+
* Handles three cases:
355+
* <ul>
356+
* <li>Leaf conditions (comparisons like EQUAL, GREATER_THAN, etc.)</li>
357+
* <li>NOT-wrapped leaf conditions (unwraps NOT, unwraps nested ANY, negates operator)</li>
358+
* <li>NOT-wrapped AND/OR (applies De Morgan's laws recursively)</li>
359+
* </ul>
360+
*
361+
* @param call the condition call to process
362+
* @return a Bson filter for this condition
363+
*/
364+
private Bson buildLeafCondition(Call call) {
365+
Operator operator = call.operator();
366+
367+
// Handle NOT operator by unwrapping and negating the inner operator
368+
if (operator == Operators.NOT) {
369+
Preconditions.checkArgument(call.arguments().get(0) instanceof Call, "%s is not a call", call.arguments().get(0));
370+
call = (Call) call.arguments().get(0);
371+
372+
// Unwrap any nested ANY operators (e.g., pet.type.not(type -> type.is(...)))
373+
while (call.operator() == IterableOperators.ANY) {
374+
Preconditions.checkArgument(call.arguments().size() == 2, "ANY should have 2 arguments");
375+
Preconditions.checkArgument(call.arguments().get(1) instanceof Call, "Second argument of ANY should be a Call");
376+
call = (Call) call.arguments().get(1);
377+
}
378+
379+
// If after unwrapping we find AND/OR, apply De Morgan's laws
380+
if (call.operator() == Operators.AND || call.operator() == Operators.OR) {
381+
return applyDeMorgansLaw(call);
382+
}
383+
384+
operator = negateOperator(call.operator());
385+
} else {
386+
operator = call.operator();
387+
}
388+
389+
// Extract and reconstruct the path
390+
Preconditions.checkArgument(call.arguments().get(0) instanceof Path, "%s is not a path", call.arguments().get(0));
391+
Path fullPath = reconstructFullPath(Visitors.toPath(call.arguments().get(0)));
392+
393+
// Build the final condition
394+
return buildCondition(call, operator, fullPath);
395+
}
396+
397+
/**
398+
* Applies De Morgan's laws to negate a logical operator (AND/OR).
399+
* <p>
400+
* Transformations:
401+
* <ul>
402+
* <li>NOT(a AND b) = NOT(a) OR NOT(b)</li>
403+
* <li>NOT(a OR b) = NOT(a) AND NOT(b)</li>
404+
* </ul>
405+
*
406+
* @param call an AND or OR call to be negated
407+
* @return the negated condition with flipped operator
408+
*/
409+
private Bson applyDeMorgansLaw(Call call) {
410+
Operator op = call.operator();
411+
List<Bson> negatedConditions = new ArrayList<>();
412+
413+
for (Expression arg : call.arguments()) {
414+
// Wrap each argument in NOT and process it
415+
Call negatedArg = Expressions.unaryCall(Operators.NOT, (Call) arg);
416+
negatedConditions.add(buildLeafCondition(negatedArg));
417+
}
418+
419+
// Apply De Morgan's law: flip AND <-> OR
420+
return op == Operators.AND ? Filters.or(negatedConditions) : Filters.and(negatedConditions);
421+
}
422+
423+
/**
424+
* Builds a MongoDB filter from a call, operator, and path.
425+
* Creates either a unary expression (using the call's original operator) or
426+
* a binary expression (using the provided operator, which may be negated).
427+
*
428+
* @param call the original call (used for arity and second argument if binary)
429+
* @param operator the operator to use (may be negated from call's operator)
430+
* @param path the path to use in the expression
431+
* @return a Bson filter for this condition
432+
*/
433+
private Bson buildCondition(Call call, Operator operator, Path path) {
434+
if (call.operator().arity() == Operator.Arity.UNARY) {
435+
return visit(Expressions.unaryCall(call.operator(), path));
436+
} else {
437+
return binaryCall(Expressions.binaryCall(operator, path, call.arguments().get(1)));
438+
}
439+
}
440+
441+
/**
442+
* Reconstructs a full path from a potentially partial path by walking up the parent chain.
443+
*/
444+
private static Path reconstructFullPath(Path path) {
445+
List<Path> paths = new ArrayList<>();
446+
Path current = path;
447+
while (current.parent().isPresent()) {
448+
paths.add(current);
449+
current = current.parent().get();
450+
}
451+
Collections.reverse(paths);
452+
453+
Path result = null;
454+
for (Path p : paths) {
455+
result = Path.combine(result, p);
456+
}
457+
return result;
458+
}
459+
314460
/**
315461
* Visitor used when special {@code $expr} needs to be generated like {@code field1 == field2}
316462
* in mongo it would look like:

0 commit comments

Comments
 (0)