Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2097,6 +2097,23 @@ private QsNode processLuceneBooleanChain(SearchParser.OrClauseContext ctx) {
// Apply Lucene boolean logic
applyLuceneBooleanLogic(terms);

// Check if ALL terms are MUST_NOT (pure negation query).
// In Lucene, a BooleanQuery with only MUST_NOT clauses matches nothing,
// so we inject a MATCH_ALL_DOCS(SHOULD) node to ensure proper semantics:
// match all docs EXCEPT those matching any MUST_NOT term.
boolean allMustNot = terms.stream().allMatch(t -> t.occur == QsOccur.MUST_NOT);
if (allMustNot) {
QsNode matchAllNode = new QsNode(QsClauseType.MATCH_ALL_DOCS, (List<QsNode>) null);
matchAllNode.setOccur(QsOccur.SHOULD);
List<QsNode> children = new ArrayList<>();
children.add(matchAllNode);
for (TermWithOccur term : terms) {
term.node.setOccur(term.occur);
children.add(term.node);
}
return new QsNode(QsClauseType.OCCUR_BOOLEAN, children, 1);
}

// Determine minimum_should_match
// Only use explicit option at top level; nested clauses use default logic
Integer minShouldMatch = (nestingLevel == 0) ? options.getMinimumShouldMatch() : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,60 @@ public void testLuceneModeNotOperator() {
Assertions.assertEquals(QsOccur.MUST_NOT, termNode.getOccur());
}

@Test
public void testLuceneModeMultipleNotTermsInjectMatchAllDocs() {
// Test: "NOT a AND NOT b" should inject MATCH_ALL_DOCS(SHOULD) when ALL terms are MUST_NOT
String dsl = "NOT field:a AND NOT field:b";
String options = "{\"mode\":\"lucene\"}";
QsPlan plan = SearchDslParser.parseDsl(dsl, options);

Assertions.assertNotNull(plan);
Assertions.assertEquals(QsClauseType.OCCUR_BOOLEAN, plan.getRoot().getType());
// 3 children: MATCH_ALL_DOCS(SHOULD) + MUST_NOT(a) + MUST_NOT(b)
Assertions.assertEquals(3, plan.getRoot().getChildren().size());
Assertions.assertEquals(Integer.valueOf(1), plan.getRoot().getMinimumShouldMatch());

QsNode matchAllNode = plan.getRoot().getChildren().get(0);
Assertions.assertEquals(QsClauseType.MATCH_ALL_DOCS, matchAllNode.getType());
Assertions.assertEquals(QsOccur.SHOULD, matchAllNode.getOccur());

for (int i = 1; i < plan.getRoot().getChildren().size(); i++) {
Assertions.assertEquals(QsOccur.MUST_NOT, plan.getRoot().getChildren().get(i).getOccur());
}
}

@Test
public void testLuceneModeMultipleNotImplicitConjunction() {
// Test: "NOT a NOT b" with default_operator=and
String dsl = "NOT field:a NOT field:b";
String options = "{\"mode\":\"lucene\",\"default_operator\":\"and\"}";
QsPlan plan = SearchDslParser.parseDsl(dsl, options);

Assertions.assertNotNull(plan);
Assertions.assertEquals(QsClauseType.OCCUR_BOOLEAN, plan.getRoot().getType());
Assertions.assertEquals(3, plan.getRoot().getChildren().size());

QsNode matchAllNode = plan.getRoot().getChildren().get(0);
Assertions.assertEquals(QsClauseType.MATCH_ALL_DOCS, matchAllNode.getType());
Assertions.assertEquals(QsOccur.SHOULD, matchAllNode.getOccur());
}

@Test
public void testLuceneModeNotAllMustNotNoInjection() {
// Test: "NOT a AND b" - mixed, should NOT inject MATCH_ALL_DOCS
String dsl = "NOT field:a AND field:b";
String options = "{\"mode\":\"lucene\"}";
QsPlan plan = SearchDslParser.parseDsl(dsl, options);

Assertions.assertNotNull(plan);
Assertions.assertEquals(QsClauseType.OCCUR_BOOLEAN, plan.getRoot().getType());
Assertions.assertEquals(2, plan.getRoot().getChildren().size());

boolean hasMatchAll = plan.getRoot().getChildren().stream()
.anyMatch(c -> c.getType() == QsClauseType.MATCH_ALL_DOCS);
Assertions.assertFalse(hasMatchAll, "Mixed MUST/MUST_NOT should not inject MATCH_ALL_DOCS");
}

@Test
public void testLuceneModeMinimumShouldMatchExplicit() {
// Test: explicit minimum_should_match=1 keeps SHOULD clauses
Expand Down
Loading