From 5e0be892ffcee15858b275bea125605c410f3b69 Mon Sep 17 00:00:00 2001 From: Renato Haeberli Date: Fri, 29 May 2026 17:05:38 +0200 Subject: [PATCH 1/6] skip empty BooleanQuery with Occur=SHOULD --- .../org/apache/solr/search/join/FiltersQParser.java | 9 ++++++++- .../apache/solr/search/TestMmBoolQParserPlugin.java | 11 +++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java b/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java index 451de63337a0..e706308b6649 100644 --- a/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java @@ -59,7 +59,14 @@ protected BooleanQuery parseImpl() throws SyntaxError { BooleanQuery.Builder builder = createBuilder(); for (Map.Entry clause : clauses.entrySet()) { - builder.add(unwrapQuery(clause.getKey().getQuery(), clause.getValue()), clause.getValue()); + + Query query = clause.getKey().getQuery(); + Occur occur = clause.getValue(); + + if (occur == Occur.SHOULD && query instanceof BooleanQuery boolQ && boolQ.clauses().isEmpty()) { + continue; + } + builder.add(unwrapQuery(query, occur), occur); } // what about empty query? return builder.build(); diff --git a/solr/core/src/test/org/apache/solr/search/TestMmBoolQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestMmBoolQParserPlugin.java index 3a2d976a97cb..4e81ca279973 100644 --- a/solr/core/src/test/org/apache/solr/search/TestMmBoolQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestMmBoolQParserPlugin.java @@ -109,6 +109,17 @@ public void testMinShouldMatchThresholdsLower() throws Exception { assertEquals(expected, actual); } + @Test + public void testMinShouldMatchWithEmptyClauseCausedByStopWord() throws Exception { + + Query actual = + parseQuery(req("q", "{!bool should=name:foo should=name:bar should=name_sw:to mm=-1}")); + + BooleanQuery expected = shouldBuilder("foo", "bar").setMinimumNumberShouldMatch(1).build(); + + assertEquals(expected, actual); + } + @Test public void testMinShouldMatchThresholdsUpper() throws Exception { Query actual = From eadfc22cbb6a1dd96942ff1877743d5e7c89ce73 Mon Sep 17 00:00:00 2001 From: Renato Haeberli Date: Sat, 30 May 2026 21:30:33 +0200 Subject: [PATCH 2/6] skip empty BooleanQuery with Occur=SHOULD --- solr/core/src/test-files/solr/collection1/conf/schema.xml | 1 + .../test/org/apache/solr/search/TestMmBoolQParserPlugin.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/solr/core/src/test-files/solr/collection1/conf/schema.xml b/solr/core/src/test-files/solr/collection1/conf/schema.xml index e20f31d4bf3b..4cff86be2266 100644 --- a/solr/core/src/test-files/solr/collection1/conf/schema.xml +++ b/solr/core/src/test-files/solr/collection1/conf/schema.xml @@ -156,6 +156,7 @@ + diff --git a/solr/core/src/test/org/apache/solr/search/TestMmBoolQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestMmBoolQParserPlugin.java index 4e81ca279973..a68459c32068 100644 --- a/solr/core/src/test/org/apache/solr/search/TestMmBoolQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestMmBoolQParserPlugin.java @@ -113,7 +113,7 @@ public void testMinShouldMatchThresholdsLower() throws Exception { public void testMinShouldMatchWithEmptyClauseCausedByStopWord() throws Exception { Query actual = - parseQuery(req("q", "{!bool should=name:foo should=name:bar should=name_sw:to mm=-1}")); + parseQuery(req("q", "{!bool should=name:foo should=name:bar should=teststop:to mm=-1}")); BooleanQuery expected = shouldBuilder("foo", "bar").setMinimumNumberShouldMatch(1).build(); From d8e3e8f8eb9223a57f397762ad6c87713ef3e46b Mon Sep 17 00:00:00 2001 From: Renato Haeberli Date: Mon, 1 Jun 2026 09:50:15 +0200 Subject: [PATCH 3/6] override SolrQueryParserBase.createFieldQuery in order to handle null case --- .../solr/parser/SolrQueryParserBase.java | 18 ++++++++++++++++++ .../solr/search/join/FiltersQParser.java | 5 +++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/parser/SolrQueryParserBase.java b/solr/core/src/java/org/apache/solr/parser/SolrQueryParserBase.java index 42cedf829799..6ba66c680361 100644 --- a/solr/core/src/java/org/apache/solr/parser/SolrQueryParserBase.java +++ b/solr/core/src/java/org/apache/solr/parser/SolrQueryParserBase.java @@ -538,6 +538,24 @@ protected Query newFieldQuery( return query; } + /** + * Delegates to {@link QueryBuilder#createFieldQuery(org.apache.lucene.analysis.Analyzer, + * org.apache.lucene.search.BooleanClause.Occur, java.lang.String, java.lang.String, boolean, + * int)} but returns MatchNoDocsQuery rather than null + */ + protected Query createFieldQuery( + Analyzer analyzer, + BooleanClause.Occur operator, + String field, + String queryText, + boolean quoted, + int phraseSlop) { + + Query fieldQuery = + super.createFieldQuery(analyzer, operator, field, queryText, quoted, phraseSlop); + return fieldQuery != null ? fieldQuery : new MatchNoDocsQuery("empty query"); + } + /** * Base implementation delegates to {@link #getFieldQuery(String,String,boolean,boolean)}. This * method may be overridden, for example, to return a SpanNearQuery instead of a PhraseQuery. diff --git a/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java b/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java index e706308b6649..b66c5fb37e36 100644 --- a/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java @@ -26,6 +26,7 @@ import org.apache.lucene.search.BooleanClause.Occur; import org.apache.lucene.search.BooleanQuery; import org.apache.lucene.search.MatchAllDocsQuery; +import org.apache.lucene.search.MatchNoDocsQuery; import org.apache.lucene.search.Query; import org.apache.solr.common.params.SolrParams; import org.apache.solr.common.util.StrUtils; @@ -63,8 +64,8 @@ protected BooleanQuery parseImpl() throws SyntaxError { Query query = clause.getKey().getQuery(); Occur occur = clause.getValue(); - if (occur == Occur.SHOULD && query instanceof BooleanQuery boolQ && boolQ.clauses().isEmpty()) { - continue; + if (query instanceof MatchNoDocsQuery) { + continue; } builder.add(unwrapQuery(query, occur), occur); } From 408d3d08ad243019c54f0e6e153e78a18e8040e3 Mon Sep 17 00:00:00 2001 From: Renato Haeberli Date: Mon, 1 Jun 2026 11:56:48 +0200 Subject: [PATCH 4/6] override SolrQueryParserBase.createFieldQuery in order to handle null case --- .../java/org/apache/solr/search/join/FiltersQParser.java | 4 ---- .../src/java/org/apache/solr/util/SolrPluginUtils.java | 3 ++- .../org/apache/solr/search/TestMmBoolQParserPlugin.java | 8 ++++++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java b/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java index b66c5fb37e36..6e864ba36738 100644 --- a/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java @@ -26,7 +26,6 @@ import org.apache.lucene.search.BooleanClause.Occur; import org.apache.lucene.search.BooleanQuery; import org.apache.lucene.search.MatchAllDocsQuery; -import org.apache.lucene.search.MatchNoDocsQuery; import org.apache.lucene.search.Query; import org.apache.solr.common.params.SolrParams; import org.apache.solr.common.util.StrUtils; @@ -64,9 +63,6 @@ protected BooleanQuery parseImpl() throws SyntaxError { Query query = clause.getKey().getQuery(); Occur occur = clause.getValue(); - if (query instanceof MatchNoDocsQuery) { - continue; - } builder.add(unwrapQuery(query, occur), occur); } // what about empty query? diff --git a/solr/core/src/java/org/apache/solr/util/SolrPluginUtils.java b/solr/core/src/java/org/apache/solr/util/SolrPluginUtils.java index ed6178be4f9b..3a1a3f0cd3d9 100644 --- a/solr/core/src/java/org/apache/solr/util/SolrPluginUtils.java +++ b/solr/core/src/java/org/apache/solr/util/SolrPluginUtils.java @@ -46,6 +46,7 @@ import org.apache.lucene.search.BoostQuery; import org.apache.lucene.search.DisjunctionMaxQuery; import org.apache.lucene.search.Explanation; +import org.apache.lucene.search.MatchNoDocsQuery; import org.apache.lucene.search.Query; import org.apache.lucene.search.Sort; import org.apache.solr.common.SolrException; @@ -595,7 +596,7 @@ public static void setMinShouldMatch(BooleanQuery.Builder q, String spec, boolea optionalDismaxClauses++; } } else { - optionalClauses++; + if (!(c.query() instanceof MatchNoDocsQuery)) optionalClauses++; } } } diff --git a/solr/core/src/test/org/apache/solr/search/TestMmBoolQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestMmBoolQParserPlugin.java index a68459c32068..f55df0ced89a 100644 --- a/solr/core/src/test/org/apache/solr/search/TestMmBoolQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestMmBoolQParserPlugin.java @@ -20,6 +20,7 @@ import org.apache.lucene.index.Term; import org.apache.lucene.search.BooleanClause; import org.apache.lucene.search.BooleanQuery; +import org.apache.lucene.search.MatchNoDocsQuery; import org.apache.lucene.search.NamedMatches; import org.apache.lucene.search.Query; import org.apache.lucene.search.TermQuery; @@ -115,8 +116,11 @@ public void testMinShouldMatchWithEmptyClauseCausedByStopWord() throws Exception Query actual = parseQuery(req("q", "{!bool should=name:foo should=name:bar should=teststop:to mm=-1}")); - BooleanQuery expected = shouldBuilder("foo", "bar").setMinimumNumberShouldMatch(1).build(); - + BooleanQuery expected = + shouldBuilder("foo", "bar") + .setMinimumNumberShouldMatch(1) + .add(new MatchNoDocsQuery(""), BooleanClause.Occur.SHOULD) + .build(); assertEquals(expected, actual); } From b536b30eb00a5fd147bcde0694a1f08d0d14b0bc Mon Sep 17 00:00:00 2001 From: Renato Haeberli Date: Mon, 1 Jun 2026 11:58:58 +0200 Subject: [PATCH 5/6] override SolrQueryParserBase.createFieldQuery in order to handle null case --- .../java/org/apache/solr/search/join/FiltersQParser.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java b/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java index 6e864ba36738..451de63337a0 100644 --- a/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java +++ b/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java @@ -59,11 +59,7 @@ protected BooleanQuery parseImpl() throws SyntaxError { BooleanQuery.Builder builder = createBuilder(); for (Map.Entry clause : clauses.entrySet()) { - - Query query = clause.getKey().getQuery(); - Occur occur = clause.getValue(); - - builder.add(unwrapQuery(query, occur), occur); + builder.add(unwrapQuery(clause.getKey().getQuery(), clause.getValue()), clause.getValue()); } // what about empty query? return builder.build(); From a88d7f9878cdb583dd95a0d512ffc2d10fff8fb5 Mon Sep 17 00:00:00 2001 From: Renato Haeberli Date: Fri, 5 Jun 2026 11:02:05 +0200 Subject: [PATCH 6/6] braking up one huge test into several smaller tests --- .../apache/solr/util/SolrPluginUtilsTest.java | 184 ++++++++++-------- 1 file changed, 107 insertions(+), 77 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/util/SolrPluginUtilsTest.java b/solr/core/src/test/org/apache/solr/util/SolrPluginUtilsTest.java index 819585acc226..f4ffdb73c29e 100644 --- a/solr/core/src/test/org/apache/solr/util/SolrPluginUtilsTest.java +++ b/solr/core/src/test/org/apache/solr/util/SolrPluginUtilsTest.java @@ -124,119 +124,149 @@ public void testParseFieldBoosts() { assertEquals("spacey e2", e2, SolrPluginUtils.parseFieldBoosts(" \t ")); } - @Test - public void testDisjunctionMaxQueryParser() throws Exception { - - Query out; - String t; - - SolrQueryRequest req = req("df", "text"); - QParser qparser = QParser.getParser("hi", "dismax", req); - - DisjunctionMaxQueryParser qp = - new SolrPluginUtils.DisjunctionMaxQueryParser(qparser, req.getParams().get("df")); - - qp.addAlias( - "hoss", - 0.01f, - SolrPluginUtils.parseFieldBoosts("title^2.0 title_stemmed name^1.2 subject^0.5")); - qp.addAlias("test", 0.01f, SolrPluginUtils.parseFieldBoosts("text^2.0")); - qp.addAlias("unused", 1.0f, SolrPluginUtils.parseFieldBoosts("subject^0.5 sind^1.5")); - - /* first some sanity tests that don't use aliasing at all */ - t = "XXXXXXXX"; - out = qp.parse(t); + /* sanity: bare term with no field and no alias → TermQuery on default field */ + @Test + public void testDismaxParser_bareTermUsesDefaultField() throws Exception { + DisjunctionMaxQueryParser qp = newDismaxParserWithAliases(); + String t = "XXXXXXXX"; + Query out = qp.parse(t); assertNotNull(t + " sanity test gave back null", out); assertTrue(t + " sanity test isn't TermQuery: " + out.getClass(), out instanceof TermQuery); assertEquals( t + " sanity test is wrong field", qp.getDefaultField(), ((TermQuery) out).getTerm().field()); + } - t = "subject:XXXXXXXX"; - out = qp.parse(t); + /* sanity: explicit non-aliased field → TermQuery on that field */ + @Test + public void testDismaxParser_explicitFieldNoAlias() throws Exception { + DisjunctionMaxQueryParser qp = newDismaxParserWithAliases(); + String t = "subject:XXXXXXXX"; + Query out = qp.parse(t); assertNotNull(t + " sanity test gave back null", out); assertTrue(t + " sanity test isn't TermQuery: " + out.getClass(), out instanceof TermQuery); assertEquals(t + " sanity test is wrong field", "subject", ((TermQuery) out).getTerm().field()); + } - /* field has untokenized type, so this should be a term anyway */ - t = "sind:\"simple phrase\""; - out = qp.parse(t); + /* sanity: untokenized field type → still a TermQuery even with quoted phrase */ + @Test + public void testDismaxParser_quotedPhraseOnUntokenizedFieldIsTermQuery() throws Exception { + DisjunctionMaxQueryParser qp = newDismaxParserWithAliases(); + String t = "sind:\"simple phrase\""; + Query out = qp.parse(t); assertNotNull(t + " sanity test gave back null", out); assertTrue(t + " sanity test isn't TermQuery: " + out.getClass(), out instanceof TermQuery); assertEquals(t + " sanity test is wrong field", "sind", ((TermQuery) out).getTerm().field()); + } - t = "subject:\"simple phrase\""; - out = qp.parse(t); + /* sanity: tokenized field with quoted phrase → PhraseQuery */ + @Test + public void testDismaxParser_quotedPhraseOnTokenizedFieldIsPhraseQuery() throws Exception { + DisjunctionMaxQueryParser qp = newDismaxParserWithAliases(); + String t = "subject:\"simple phrase\""; + Query out = qp.parse(t); assertNotNull(t + " sanity test gave back null", out); assertTrue(t + " sanity test isn't PhraseQuery: " + out.getClass(), out instanceof PhraseQuery); assertEquals( t + " sanity test is wrong field", "subject", ((PhraseQuery) out).getTerms()[0].field()); + } - /* now some tests that use aliasing */ - - /* basic usage of single "term" */ - t = "hoss:XXXXXXXX"; - out = qp.parse(t); + /* basic alias use: single term on an alias mapping to 4 fields → DMQ with 4 clauses */ + @Test + public void testDismaxParser_aliasedTermProducesDMQClausePerField() throws Exception { + DisjunctionMaxQueryParser qp = newDismaxParserWithAliases(); + String t = "hoss:XXXXXXXX"; + Query out = qp.parse(t); assertNotNull(t + " was null", out); assertTrue(t + " wasn't a DMQ:" + out.getClass(), out instanceof DisjunctionMaxQuery); assertEquals( t + " wrong number of clauses", 4, countItems(((DisjunctionMaxQuery) out).iterator())); + } - /* odd case, but should still work, DMQ of one clause */ - t = "test:YYYYY"; - out = qp.parse(t); + /* edge case: alias points to a single field → DMQ with 1 clause */ + @Test + public void testDismaxParser_singleFieldAliasIsDMQWithOneClause() throws Exception { + DisjunctionMaxQueryParser qp = newDismaxParserWithAliases(); + String t = "test:YYYYY"; + Query out = qp.parse(t); assertNotNull(t + " was null", out); assertTrue(t + " wasn't a DMQ:" + out.getClass(), out instanceof DisjunctionMaxQuery); assertEquals( t + " wrong number of clauses", 1, countItems(((DisjunctionMaxQuery) out).iterator())); + } - /* basic usage of multiple "terms" */ - t = "hoss:XXXXXXXX test:YYYYY"; - out = qp.parse(t); + /* multiple aliased terms → BooleanQuery whose clauses are DMQs */ + @Test + public void testDismaxParser_multipleAliasedTermsProduceBooleanOfDMQs() throws Exception { + DisjunctionMaxQueryParser qp = newDismaxParserWithAliases(); + String t = "hoss:XXXXXXXX test:YYYYY"; + Query out = qp.parse(t); assertNotNull(t + " was null", out); assertTrue(t + " wasn't a boolean:" + out.getClass(), out instanceof BooleanQuery); - { - BooleanQuery bq = (BooleanQuery) out; - List clauses = new ArrayList<>(bq.clauses()); - assertEquals(t + " wrong number of clauses", 2, clauses.size()); - Query sub = clauses.get(0).query(); - assertTrue(t + " first wasn't a DMQ:" + sub.getClass(), sub instanceof DisjunctionMaxQuery); - assertEquals( - t + " first had wrong number of clauses", - 4, - countItems(((DisjunctionMaxQuery) sub).iterator())); - sub = clauses.get(1).query(); - assertTrue(t + " second wasn't a DMQ:" + sub.getClass(), sub instanceof DisjunctionMaxQuery); - assertEquals( - t + " second had wrong number of clauses", - 1, - countItems(((DisjunctionMaxQuery) sub).iterator())); - } - /* a phrase and a term that is a stop word for some fields */ - t = "hoss:\"XXXXXX YYYYY\" hoss:the"; - out = qp.parse(t); + BooleanQuery bq = (BooleanQuery) out; + List clauses = new ArrayList<>(bq.clauses()); + assertEquals(t + " wrong number of clauses", 2, clauses.size()); + + Query first = clauses.get(0).query(); + assertTrue(t + " first wasn't a DMQ:" + first.getClass(), first instanceof DisjunctionMaxQuery); + assertEquals( + t + " first had wrong number of clauses", + 4, + countItems(((DisjunctionMaxQuery) first).iterator())); + + Query second = clauses.get(1).query(); + assertTrue( + t + " second wasn't a DMQ:" + second.getClass(), second instanceof DisjunctionMaxQuery); + assertEquals( + t + " second had wrong number of clauses", + 1, + countItems(((DisjunctionMaxQuery) second).iterator())); + } + + /* phrase + a term that is a stop word in some of the aliased fields */ + @Test + public void testDismaxParser_stopWordReducesDMQClauseCount() throws Exception { + DisjunctionMaxQueryParser qp = newDismaxParserWithAliases(); + String t = "hoss:\"XXXXXX YYYYY\" hoss:the"; + Query out = qp.parse(t); assertNotNull(t + " was null", out); assertTrue(t + " wasn't a boolean:" + out.getClass(), out instanceof BooleanQuery); - { - BooleanQuery bq = (BooleanQuery) out; - List clauses = new ArrayList<>(bq.clauses()); - assertEquals(t + " wrong number of clauses", 2, clauses.size()); - Query sub = clauses.get(0).query(); - assertTrue(t + " first wasn't a DMQ:" + sub.getClass(), sub instanceof DisjunctionMaxQuery); - assertEquals( - t + " first had wrong number of clauses", - 4, - countItems(((DisjunctionMaxQuery) sub).iterator())); - sub = clauses.get(1).query(); - assertTrue(t + " second wasn't a DMQ:" + sub.getClass(), sub instanceof DisjunctionMaxQuery); - assertEquals( - t + " second had wrong number of clauses (stop words)", - 2, - countItems(((DisjunctionMaxQuery) sub).iterator())); - } + + BooleanQuery bq = (BooleanQuery) out; + List clauses = new ArrayList<>(bq.clauses()); + assertEquals(t + " wrong number of clauses", 2, clauses.size()); + + Query first = clauses.get(0).query(); + assertTrue(t + " first wasn't a DMQ:" + first.getClass(), first instanceof DisjunctionMaxQuery); + assertEquals( + t + " first had wrong number of clauses", + 4, + countItems(((DisjunctionMaxQuery) first).iterator())); + + Query second = clauses.get(1).query(); + assertTrue( + t + " second wasn't a DMQ:" + second.getClass(), second instanceof DisjunctionMaxQuery); + assertEquals( + t + " second had wrong number of clauses (stop words)", + 2, + countItems(((DisjunctionMaxQuery) second).iterator())); + } + + private DisjunctionMaxQueryParser newDismaxParserWithAliases() throws Exception { + SolrQueryRequest req = req("df", "text"); + QParser qparser = QParser.getParser("hi", "dismax", req); + DisjunctionMaxQueryParser qp = + new SolrPluginUtils.DisjunctionMaxQueryParser(qparser, req.getParams().get("df")); + qp.addAlias( + "hoss", + 0.01f, + SolrPluginUtils.parseFieldBoosts("title^2.0 title_stemmed name^1.2 subject^0.5")); + qp.addAlias("test", 0.01f, SolrPluginUtils.parseFieldBoosts("text^2.0")); + qp.addAlias("unused", 1.0f, SolrPluginUtils.parseFieldBoosts("subject^0.5 sind^1.5")); + return qp; } private static int countItems(Iterator i) {