This is an automated email from the ASF dual-hosted git repository.
airborne pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 6cd22c9fdcc [feat](search) Support field-grouped query syntax
field:(term1 OR term2) (#60786)
6cd22c9fdcc is described below
commit 6cd22c9fdcc52ff803e6c2add348d0f634188c9f
Author: Jack <[email protected]>
AuthorDate: Sat Feb 21 12:05:23 2026 +0800
[feat](search) Support field-grouped query syntax field:(term1 OR term2)
(#60786)
### What problem does this PR solve?
Issue Number: close #N/A
Problem Summary:
The `search()` function did not support ES `query_string` field-grouped
syntax where all terms inside parentheses inherit the field prefix:
```sql
-- Previously failed with syntax error
SELECT * FROM t WHERE search('title:(rock OR jazz)',
'{"fields":["title","content"]}');
```
ES semantics:
| Input | Expansion |
|-------|-----------|
| `title:(rock OR jazz)` | `(title:rock OR title:jazz)` |
| `title:(rock jazz)` with `default_operator:AND` | `(+title:rock
+title:jazz)` |
| `title:(rock OR jazz) AND music` with `fields:[title,content]` |
`(title:rock OR title:jazz) AND (title:music OR content:music)` |
| `title:("rock and roll" OR jazz)` | `(title:"rock and roll" OR
title:jazz)` |
### Root cause
The ANTLR grammar `SearchParser.g4` defined `fieldQuery : fieldPath
COLON searchValue` where `searchValue` only accepts leaf values (TERM,
QUOTED, etc.), not a parenthesized sub-clause. So `title:(` caused a
syntax error.
### Solution
**Grammar** (`SearchParser.g4`):
- Add `fieldGroupQuery : fieldPath COLON LPAREN clause RPAREN` rule
- Add it as alternative in `atomClause` before `fieldQuery`
**Visitor** (`SearchDslParser.java`):
- Add `markExplicitFieldRecursive()` helper — marks all leaf nodes in a
group as `explicitField=true` to prevent `MultiFieldExpander` from
re-expanding them across unintended fields
- Modify `visitBareQuery()` in both `QsAstBuilder` and
`QsLuceneModeAstBuilder` to use `currentFieldName` as field group
context when set
- Add `visitFieldGroupQuery()` to both AST builders: sets field context,
visits inner clause, marks all leaves explicit
- Update `visitAtomClause()` and `collectTermsFromNotClause()` to handle
---
.../apache/doris/nereids/search/SearchParser.g4 | 9 +-
.../functions/scalar/SearchDslParser.java | 167 ++++++++++-
.../functions/scalar/SearchDslParserTest.java | 321 ++++++++++++++++++++-
.../search/test_search_boundary_cases.groovy | 3 +
.../test_search_default_field_operator.groovy | 3 +
.../suites/search/test_search_dsl_operators.groovy | 3 +
.../suites/search/test_search_escape.groovy | 3 +
.../suites/search/test_search_exact_basic.groovy | 3 +
.../search/test_search_exact_lowercase.groovy | 3 +
.../suites/search/test_search_exact_match.groovy | 3 +
.../search/test_search_exact_multi_index.groovy | 3 +
.../search/test_search_field_group_query.groovy | 205 +++++++++++++
.../suites/search/test_search_lucene_mode.groovy | 3 +
.../suites/search/test_search_multi_field.groovy | 3 +
.../search/test_search_null_regression.groovy | 3 +
.../search/test_search_null_semantics.groovy | 3 +
.../search/test_search_regexp_lowercase.groovy | 3 +
.../test_search_variant_dual_index_reader.groovy | 2 +
.../test_search_variant_subcolumn_analyzer.groovy | 5 +
.../search/test_search_vs_match_consistency.groovy | 3 +
20 files changed, 738 insertions(+), 13 deletions(-)
diff --git
a/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/search/SearchParser.g4
b/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/search/SearchParser.g4
index cc5f6082cd6..3ff445ea1d6 100644
--- a/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/search/SearchParser.g4
+++ b/fe/fe-core/src/main/antlr4/org/apache/doris/nereids/search/SearchParser.g4
@@ -25,9 +25,14 @@ orClause : andClause (OR andClause)* ;
// AND is optional - space-separated terms use default_operator
andClause : notClause (AND? notClause)* ;
notClause : NOT atomClause | atomClause ;
-// Note: fieldQuery is listed before bareQuery so ANTLR prioritizes
field:value over bare value.
+// Note: fieldGroupQuery is listed before fieldQuery so ANTLR prioritizes
field:(group) over field:value.
+// fieldQuery is listed before bareQuery so ANTLR prioritizes field:value over
bare value.
// This ensures "field:term" is parsed as fieldQuery, not bareQuery with
"field" as term.
-atomClause : LPAREN clause RPAREN | fieldQuery | bareQuery ;
+atomClause : LPAREN clause RPAREN | fieldGroupQuery | fieldQuery | bareQuery ;
+
+// Support for field:(grouped query) syntax, e.g., title:(rock OR jazz)
+// All terms inside the parentheses inherit the field prefix.
+fieldGroupQuery : fieldPath COLON LPAREN clause RPAREN ;
// Support for variant subcolumn paths (e.g., field.subcolumn, field.sub1.sub2)
fieldQuery : fieldPath COLON searchValue ;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/SearchDslParser.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/SearchDslParser.java
index 14e55fce15c..db12e715bed 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/SearchDslParser.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/SearchDslParser.java
@@ -62,6 +62,7 @@ import javax.annotation.Nullable;
public class SearchDslParser {
private static final Logger LOG =
LogManager.getLogger(SearchDslParser.class);
private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
+ private static final int MAX_FIELD_GROUP_DEPTH = 32;
/**
* Exception for search DSL syntax errors.
@@ -306,6 +307,43 @@ public class SearchDslParser {
}
}
+ /**
+ * Recursively mark leaf nodes with the given field name and set
explicitField=true.
+ * Used for field-grouped queries like title:(rock OR jazz) to ensure
inner leaf nodes
+ * are bound to the group's field and are not re-expanded by
MultiFieldExpander.
+ *
+ * Skips nodes already marked as explicitField to preserve inner explicit
bindings,
+ * e.g., title:(content:foo OR bar) keeps content:foo intact and only sets
title on bar.
+ *
+ * @param depth current recursion depth to prevent StackOverflow from
malicious input
+ */
+ private static void markExplicitFieldRecursive(QsNode node, String field) {
+ markExplicitFieldRecursive(node, field, 0);
+ }
+
+ private static void markExplicitFieldRecursive(QsNode node, String field,
int depth) {
+ if (node == null) {
+ return;
+ }
+ if (depth > MAX_FIELD_GROUP_DEPTH) {
+ throw new SearchDslSyntaxException(
+ "Field group query nesting too deep (max " +
MAX_FIELD_GROUP_DEPTH + ")");
+ }
+ // Skip nodes already explicitly bound to a field (e.g., inner
field:term inside a group)
+ if (node.isExplicitField()) {
+ return;
+ }
+ if (node.getChildren() != null && !node.getChildren().isEmpty()) {
+ for (QsNode child : node.getChildren()) {
+ markExplicitFieldRecursive(child, field, depth + 1);
+ }
+ } else {
+ // Leaf node - set field and mark as explicit
+ node.setField(field);
+ node.setExplicitField(true);
+ }
+ }
+
/**
* Common ANTLR parsing helper with visitor pattern.
* Reduces code duplication across parsing methods.
@@ -753,6 +791,13 @@ public class SearchDslParser {
}
return result;
}
+ if (ctx.fieldGroupQuery() != null) {
+ QsNode result = visit(ctx.fieldGroupQuery());
+ if (result == null) {
+ throw new SearchDslSyntaxException("Invalid field group
query");
+ }
+ return result;
+ }
if (ctx.fieldQuery() != null) {
QsNode result = visit(ctx.fieldQuery());
if (result == null) {
@@ -772,18 +817,21 @@ public class SearchDslParser {
@Override
public QsNode visitBareQuery(SearchParser.BareQueryContext ctx) {
- // Bare query - uses default field
- if (defaultField == null || defaultField.isEmpty()) {
+ // Use currentFieldName if inside a field group context (set by
visitFieldGroupQuery),
+ // otherwise fall back to the configured defaultField.
+ String effectiveField = (currentFieldName != null &&
!currentFieldName.isEmpty())
+ ? currentFieldName : defaultField;
+ if (effectiveField == null || effectiveField.isEmpty()) {
throw new SearchDslSyntaxException(
"No field specified and no default_field configured. "
+ "Either use field:value syntax or set default_field in
options.");
}
- fieldNames.add(defaultField);
+ fieldNames.add(effectiveField);
- // Set current field context to default field before visiting
search value
+ // Set current field context before visiting search value
String previousFieldName = currentFieldName;
- currentFieldName = defaultField;
+ currentFieldName = effectiveField;
try {
if (ctx.searchValue() == null) {
@@ -846,6 +894,50 @@ public class SearchDslParser {
}
}
+ @Override
+ public QsNode visitFieldGroupQuery(SearchParser.FieldGroupQueryContext
ctx) {
+ if (ctx.fieldPath() == null) {
+ throw new SearchDslSyntaxException("Invalid field group query:
missing field path");
+ }
+
+ // Build complete field path from segments (support
field.subcolumn syntax)
+ StringBuilder fullPath = new StringBuilder();
+ List<SearchParser.FieldSegmentContext> segments =
ctx.fieldPath().fieldSegment();
+ for (int i = 0; i < segments.size(); i++) {
+ if (i > 0) {
+ fullPath.append('.');
+ }
+ String segment = segments.get(i).getText();
+ if (segment.startsWith("\"") && segment.endsWith("\"")) {
+ segment = segment.substring(1, segment.length() - 1);
+ }
+ fullPath.append(segment);
+ }
+
+ String fieldPath = fullPath.toString();
+ fieldNames.add(fieldPath);
+
+ // Set field group context so bare terms inside use this field
+ String previousFieldName = currentFieldName;
+ currentFieldName = fieldPath;
+
+ try {
+ if (ctx.clause() == null) {
+ throw new SearchDslSyntaxException("Invalid field group
query: missing inner clause");
+ }
+ QsNode result = visit(ctx.clause());
+ if (result == null) {
+ throw new SearchDslSyntaxException("Invalid field group
query: inner clause returned null");
+ }
+ // Mark all leaf nodes as explicitly bound to this field.
+ // This prevents MultiFieldExpander from re-expanding them
across other fields.
+ markExplicitFieldRecursive(result, fieldPath);
+ return result;
+ } finally {
+ currentFieldName = previousFieldName;
+ }
+ }
+
@Override
public QsNode visitSearchValue(SearchParser.SearchValueContext ctx) {
String fieldName = getCurrentFieldName();
@@ -2102,6 +2194,9 @@ public class SearchDslParser {
} finally {
nestingLevel--;
}
+ } else if (atomCtx.fieldGroupQuery() != null) {
+ // Field group query (e.g., title:(rock OR jazz))
+ node = visit(atomCtx.fieldGroupQuery());
} else if (atomCtx.fieldQuery() != null) {
// Field query with explicit field prefix
node = visit(atomCtx.fieldQuery());
@@ -2245,6 +2340,9 @@ public class SearchDslParser {
if (ctx.clause() != null) {
return visit(ctx.clause());
}
+ if (ctx.fieldGroupQuery() != null) {
+ return visit(ctx.fieldGroupQuery());
+ }
if (ctx.fieldQuery() != null) {
return visit(ctx.fieldQuery());
}
@@ -2256,19 +2354,22 @@ public class SearchDslParser {
@Override
public QsNode visitBareQuery(SearchParser.BareQueryContext ctx) {
- // Bare query - uses effective default field (considering override)
+ // Use currentFieldName if inside a field group context (set by
visitFieldGroupQuery),
+ // otherwise fall back to the effective default field.
String defaultField = getEffectiveDefaultField();
- if (defaultField == null || defaultField.isEmpty()) {
+ String effectiveField = (currentFieldName != null &&
!currentFieldName.isEmpty())
+ ? currentFieldName : defaultField;
+ if (effectiveField == null || effectiveField.isEmpty()) {
throw new SearchDslSyntaxException(
"No field specified and no default_field configured. "
+ "Either use field:value syntax or set default_field in
options.");
}
- fieldNames.add(defaultField);
+ fieldNames.add(effectiveField);
- // Set current field context to default field before visiting
search value
+ // Set current field context before visiting search value
String previousFieldName = currentFieldName;
- currentFieldName = defaultField;
+ currentFieldName = effectiveField;
try {
if (ctx.searchValue() == null) {
@@ -2318,6 +2419,52 @@ public class SearchDslParser {
}
}
+ @Override
+ public QsNode visitFieldGroupQuery(SearchParser.FieldGroupQueryContext
ctx) {
+ if (ctx.fieldPath() == null) {
+ throw new SearchDslSyntaxException("Invalid field group query:
missing field path");
+ }
+
+ // Build complete field path from segments (support
field.subcolumn syntax)
+ StringBuilder fullPath = new StringBuilder();
+ List<SearchParser.FieldSegmentContext> segments =
ctx.fieldPath().fieldSegment();
+ for (int i = 0; i < segments.size(); i++) {
+ if (i > 0) {
+ fullPath.append('.');
+ }
+ String segment = segments.get(i).getText();
+ if (segment.startsWith("\"") && segment.endsWith("\"")) {
+ segment = segment.substring(1, segment.length() - 1);
+ }
+ fullPath.append(segment);
+ }
+
+ String fieldPath = fullPath.toString();
+ fieldNames.add(fieldPath);
+
+ // Set field group context so bare terms inside use this field
+ String previousFieldName = currentFieldName;
+ currentFieldName = fieldPath;
+ nestingLevel++;
+
+ try {
+ if (ctx.clause() == null) {
+ throw new SearchDslSyntaxException("Invalid field group
query: missing inner clause");
+ }
+ QsNode result = visit(ctx.clause());
+ if (result == null) {
+ throw new SearchDslSyntaxException("Invalid field group
query: inner clause returned null");
+ }
+ // Mark all leaf nodes as explicitly bound to this field.
+ // This prevents MultiFieldExpander from re-expanding them
across other fields.
+ markExplicitFieldRecursive(result, fieldPath);
+ return result;
+ } finally {
+ nestingLevel--;
+ currentFieldName = previousFieldName;
+ }
+ }
+
@Override
public QsNode visitSearchValue(SearchParser.SearchValueContext ctx) {
String fieldName = currentFieldName;
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/functions/scalar/SearchDslParserTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/functions/scalar/SearchDslParserTest.java
index 704874fa0d9..9b40b05305e 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/functions/scalar/SearchDslParserTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/functions/scalar/SearchDslParserTest.java
@@ -1541,6 +1541,60 @@ public class SearchDslParserTest {
Assertions.assertEquals(QsClauseType.REGEXP,
regexpGroup.getChildren().get(1).getType());
}
+ @Test
+ public void testMultiFieldExplicitFieldNotExpanded() {
+ // Bug #1: explicit field prefix (field:term) should NOT be expanded
across fields,
+ // even when the field is in the fields list. Matches ES query_string
behavior.
+ // "title:music AND content:history" → +title:music +content:history
(no expansion)
+ String dsl = "title:music AND content:history";
+ String options =
"{\"fields\":[\"title\",\"content\"],\"default_operator\":\"and\",\"mode\":\"lucene\",\"type\":\"best_fields\"}";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, options);
+
+ Assertions.assertNotNull(plan);
+ QsNode root = plan.getRoot();
+ Assertions.assertEquals(QsClauseType.OCCUR_BOOLEAN, root.getType());
+ Assertions.assertEquals(2, root.getChildren().size());
+
+ // First child: title:music - should be a TERM pinned to "title", NOT
expanded
+ QsNode musicNode = root.getChildren().get(0);
+ Assertions.assertEquals(QsClauseType.TERM, musicNode.getType());
+ Assertions.assertEquals("title", musicNode.getField());
+ Assertions.assertEquals("music", musicNode.getValue());
+
+ // Second child: content:history - should be a TERM pinned to
"content", NOT expanded
+ QsNode historyNode = root.getChildren().get(1);
+ Assertions.assertEquals(QsClauseType.TERM, historyNode.getType());
+ Assertions.assertEquals("content", historyNode.getField());
+ Assertions.assertEquals("history", historyNode.getValue());
+ }
+
+ @Test
+ public void testMultiFieldMixedExplicitAndBareTerms() {
+ // "title:football AND american" → +title:football +(title:american |
content:american)
+ // Explicit field pinned, bare term expanded
+ String dsl = "title:football AND american";
+ String options =
"{\"fields\":[\"title\",\"content\"],\"default_operator\":\"and\",\"mode\":\"lucene\",\"type\":\"best_fields\"}";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, options);
+
+ Assertions.assertNotNull(plan);
+ QsNode root = plan.getRoot();
+ Assertions.assertEquals(QsClauseType.OCCUR_BOOLEAN, root.getType());
+ Assertions.assertEquals(2, root.getChildren().size());
+
+ // First child: title:football - pinned to "title"
+ QsNode footballNode = root.getChildren().get(0);
+ Assertions.assertEquals(QsClauseType.TERM, footballNode.getType());
+ Assertions.assertEquals("title", footballNode.getField());
+ Assertions.assertEquals("football", footballNode.getValue());
+
+ // Second child: american - expanded across [title, content]
+ QsNode americanGroup = root.getChildren().get(1);
+ Assertions.assertEquals(QsClauseType.OCCUR_BOOLEAN,
americanGroup.getType());
+ Assertions.assertEquals(2, americanGroup.getChildren().size());
+ Assertions.assertEquals("title",
americanGroup.getChildren().get(0).getField());
+ Assertions.assertEquals("content",
americanGroup.getChildren().get(1).getField());
+ }
+
@Test
public void testMultiFieldCrossFieldsLuceneMode() {
// Test: cross_fields with Lucene mode
@@ -1950,8 +2004,273 @@ public class SearchDslParserTest {
Assertions.assertTrue(hasMatchAll, "Should contain MATCH_ALL_DOCS node
for '*'");
}
- // ============ Tests for MATCH_ALL_DOCS in multi-field mode ============
+ // ===== Field-Grouped Query Tests =====
+ @Test
+ public void testFieldGroupQuerySimpleOr() {
+ // title:(rock OR jazz) → OR(TERM(title,rock), TERM(title,jazz))
+ // ES semantics: field prefix applies to all terms inside parentheses
+ String dsl = "title:(rock OR jazz)";
+ QsPlan plan = SearchDslParser.parseDsl(dsl);
+
+ Assertions.assertNotNull(plan);
+ QsNode root = plan.getRoot();
+ Assertions.assertEquals(QsClauseType.OR, root.getType());
+ Assertions.assertEquals(2, root.getChildren().size());
+
+ QsNode child0 = root.getChildren().get(0);
+ QsNode child1 = root.getChildren().get(1);
+
+ Assertions.assertEquals(QsClauseType.TERM, child0.getType());
+ Assertions.assertEquals("title", child0.getField());
+ Assertions.assertEquals("rock", child0.getValue());
+ Assertions.assertTrue(child0.isExplicitField(), "term should be marked
explicit");
+
+ Assertions.assertEquals(QsClauseType.TERM, child1.getType());
+ Assertions.assertEquals("title", child1.getField());
+ Assertions.assertEquals("jazz", child1.getValue());
+ Assertions.assertTrue(child1.isExplicitField(), "term should be marked
explicit");
+
+ // Field bindings should include title
+ Assertions.assertEquals(1, plan.getFieldBindings().size());
+ Assertions.assertEquals("title",
plan.getFieldBindings().get(0).getFieldName());
+ }
+
+ @Test
+ public void testFieldGroupQueryWithAndOperator() {
+ // title:(rock jazz) with default_operator:AND → AND(TERM(title,rock),
TERM(title,jazz))
+ String dsl = "title:(rock jazz)";
+ String options = "{\"default_operator\":\"and\"}";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, options);
+
+ Assertions.assertNotNull(plan);
+ QsNode root = plan.getRoot();
+ Assertions.assertEquals(QsClauseType.AND, root.getType());
+ Assertions.assertEquals(2, root.getChildren().size());
+
+ for (QsNode child : root.getChildren()) {
+ Assertions.assertEquals(QsClauseType.TERM, child.getType());
+ Assertions.assertEquals("title", child.getField());
+ Assertions.assertTrue(child.isExplicitField(), "child should be
marked explicit");
+ }
+ Assertions.assertEquals("rock", root.getChildren().get(0).getValue());
+ Assertions.assertEquals("jazz", root.getChildren().get(1).getValue());
+ }
+
+ @Test
+ public void testFieldGroupQueryWithPhrase() {
+ // title:("rock and roll" OR jazz) → OR(PHRASE(title,"rock and roll"),
TERM(title,jazz))
+ String dsl = "title:(\"rock and roll\" OR jazz)";
+ QsPlan plan = SearchDslParser.parseDsl(dsl);
+
+ Assertions.assertNotNull(plan);
+ QsNode root = plan.getRoot();
+ Assertions.assertEquals(QsClauseType.OR, root.getType());
+ Assertions.assertEquals(2, root.getChildren().size());
+
+ QsNode phrase = root.getChildren().get(0);
+ QsNode term = root.getChildren().get(1);
+
+ Assertions.assertEquals(QsClauseType.PHRASE, phrase.getType());
+ Assertions.assertEquals("title", phrase.getField());
+ Assertions.assertEquals("rock and roll", phrase.getValue());
+ Assertions.assertTrue(phrase.isExplicitField());
+
+ Assertions.assertEquals(QsClauseType.TERM, term.getType());
+ Assertions.assertEquals("title", term.getField());
+ Assertions.assertEquals("jazz", term.getValue());
+ Assertions.assertTrue(term.isExplicitField());
+ }
+
+ @Test
+ public void testFieldGroupQueryWithWildcardAndRegexp() {
+ // title:(roc* OR /ja../) → OR(PREFIX(title,roc*), REGEXP(title,ja..))
+ String dsl = "title:(roc* OR /ja../)";
+ QsPlan plan = SearchDslParser.parseDsl(dsl);
+
+ Assertions.assertNotNull(plan);
+ QsNode root = plan.getRoot();
+ Assertions.assertEquals(QsClauseType.OR, root.getType());
+ Assertions.assertEquals(2, root.getChildren().size());
+
+ QsNode prefix = root.getChildren().get(0);
+ Assertions.assertEquals(QsClauseType.PREFIX, prefix.getType());
+ Assertions.assertEquals("title", prefix.getField());
+ Assertions.assertTrue(prefix.isExplicitField());
+
+ QsNode regexp = root.getChildren().get(1);
+ Assertions.assertEquals(QsClauseType.REGEXP, regexp.getType());
+ Assertions.assertEquals("title", regexp.getField());
+ Assertions.assertEquals("ja..", regexp.getValue());
+ Assertions.assertTrue(regexp.isExplicitField());
+ }
+
+ @Test
+ public void testFieldGroupQueryCombinedWithBareQuery() {
+ // title:(rock OR jazz) AND music → combined query
+ // In standard mode with default_field=content: explicit title terms +
expanded music
+ String dsl = "title:(rock OR jazz) AND music";
+ String options = "{\"default_field\":\"content\"}";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, options);
+
+ Assertions.assertNotNull(plan);
+ QsNode root = plan.getRoot();
+ // Root is AND
+ Assertions.assertEquals(QsClauseType.AND, root.getType());
+ Assertions.assertEquals(2, root.getChildren().size());
+ // First child is the OR group from title:(rock OR jazz)
+ QsNode orGroup = root.getChildren().get(0);
+ Assertions.assertEquals(QsClauseType.OR, orGroup.getType());
+ Assertions.assertEquals(2, orGroup.getChildren().size());
+ for (QsNode child : orGroup.getChildren()) {
+ Assertions.assertEquals("title", child.getField());
+ Assertions.assertTrue(child.isExplicitField());
+ }
+
+ // Second child is bare "music" → uses default_field "content"
+ QsNode musicNode = root.getChildren().get(1);
+ Assertions.assertEquals(QsClauseType.TERM, musicNode.getType());
+ Assertions.assertEquals("content", musicNode.getField());
+ Assertions.assertFalse(musicNode.isExplicitField());
+ }
+
+ @Test
+ public void testFieldGroupQueryMultiFieldExplicitNotExpanded() {
+ // title:(rock OR jazz) with fields=[title,content] in cross_fields
mode
+ // Explicit title:(rock OR jazz) should NOT be expanded to content
+ String dsl = "title:(rock OR jazz)";
+ String options =
"{\"fields\":[\"title\",\"content\"],\"type\":\"cross_fields\"}";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, options);
+
+ Assertions.assertNotNull(plan);
+ // Result should preserve title field for rock and jazz (not expand to
content)
+ // We verify that "content" is not a field used in the plan
+ boolean hasContentBinding = plan.getFieldBindings().stream()
+ .anyMatch(b -> "content".equals(b.getFieldName()));
+ Assertions.assertFalse(hasContentBinding,
+ "Explicit title:(rock OR jazz) should not expand to content
field");
+ }
+
+ @Test
+ public void testFieldGroupQueryLuceneMode() {
+ // title:(rock OR jazz) in lucene mode → OR(SHOULD(title:rock),
SHOULD(title:jazz))
+ String dsl = "title:(rock OR jazz)";
+ String options = "{\"mode\":\"lucene\"}";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, options);
+
+ Assertions.assertNotNull(plan);
+ QsNode root = plan.getRoot();
+ Assertions.assertNotNull(root);
+
+ // In lucene mode, the inner clause should be an OCCUR_BOOLEAN with
SHOULD children
+ Assertions.assertEquals(QsClauseType.OCCUR_BOOLEAN, root.getType());
+ Assertions.assertEquals(2, root.getChildren().size());
+
+ for (QsNode child : root.getChildren()) {
+ Assertions.assertEquals("title", child.getField());
+ Assertions.assertEquals(QsOccur.SHOULD, child.getOccur());
+ }
+ }
+
+ @Test
+ public void testFieldGroupQueryLuceneModeAndOperator() {
+ // title:(rock AND jazz) in lucene mode →
OCCUR_BOOLEAN(MUST(title:rock), MUST(title:jazz))
+ String dsl = "title:(rock AND jazz)";
+ String options = "{\"mode\":\"lucene\"}";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, options);
+
+ Assertions.assertNotNull(plan);
+ QsNode root = plan.getRoot();
+ Assertions.assertNotNull(root);
+
+ Assertions.assertEquals(QsClauseType.OCCUR_BOOLEAN, root.getType());
+ Assertions.assertEquals(2, root.getChildren().size());
+
+ for (QsNode child : root.getChildren()) {
+ Assertions.assertEquals("title", child.getField());
+ Assertions.assertEquals(QsOccur.MUST, child.getOccur());
+ }
+ }
+
+ @Test
+ public void testFieldGroupQueryLuceneModeMultiField() {
+ // title:(rock OR jazz) AND music with fields=[title,content],
mode=lucene
+ // title terms are explicit, music expands to both fields
+ String dsl = "title:(rock OR jazz) AND music";
+ String options =
"{\"fields\":[\"title\",\"content\"],\"mode\":\"lucene\"}";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, options);
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertNotNull(plan.getRoot());
+ // Should parse without error and produce a plan
+ Assertions.assertFalse(plan.getFieldBindings().isEmpty());
+ }
+
+ @Test
+ public void testFieldGroupQuerySubcolumnPath() {
+ // attrs.color:(red OR blue) - field group with dot-notation path
+ String dsl = "attrs.color:(red OR blue)";
+ QsPlan plan = SearchDslParser.parseDsl(dsl);
+
+ Assertions.assertNotNull(plan);
+ QsNode root = plan.getRoot();
+ Assertions.assertEquals(QsClauseType.OR, root.getType());
+ Assertions.assertEquals(2, root.getChildren().size());
+
+ for (QsNode child : root.getChildren()) {
+ Assertions.assertEquals("attrs.color", child.getField());
+ Assertions.assertTrue(child.isExplicitField());
+ }
+ }
+
+ @Test
+ public void testFieldGroupQueryInnerExplicitFieldPreserved() {
+ // title:(content:foo OR bar) → content:foo stays pinned to "content",
bar gets "title"
+ // Inner explicit field binding must NOT be overridden by outer group
field
+ String dsl = "title:(content:foo OR bar)";
+ QsPlan plan = SearchDslParser.parseDsl(dsl);
+
+ Assertions.assertNotNull(plan);
+ QsNode root = plan.getRoot();
+ Assertions.assertEquals(QsClauseType.OR, root.getType());
+ Assertions.assertEquals(2, root.getChildren().size());
+
+ // First child: content:foo - should keep "content" (inner explicit
binding)
+ QsNode fooNode = root.getChildren().get(0);
+ Assertions.assertEquals(QsClauseType.TERM, fooNode.getType());
+ Assertions.assertEquals("content", fooNode.getField());
+ Assertions.assertEquals("foo", fooNode.getValue());
+ Assertions.assertTrue(fooNode.isExplicitField());
+
+ // Second child: bar - should get "title" (outer group field)
+ QsNode barNode = root.getChildren().get(1);
+ Assertions.assertEquals(QsClauseType.TERM, barNode.getType());
+ Assertions.assertEquals("title", barNode.getField());
+ Assertions.assertEquals("bar", barNode.getValue());
+ Assertions.assertTrue(barNode.isExplicitField());
+ }
+
+ @Test
+ public void testFieldGroupQueryNotOperatorInside() {
+ // title:(rock OR NOT jazz) → OR(title:rock, NOT(title:jazz))
+ String dsl = "title:(rock OR NOT jazz)";
+ QsPlan plan = SearchDslParser.parseDsl(dsl);
+
+ Assertions.assertNotNull(plan);
+ QsNode root = plan.getRoot();
+ Assertions.assertEquals(QsClauseType.OR, root.getType());
+ Assertions.assertEquals(2, root.getChildren().size());
+
+ QsNode rockNode = root.getChildren().get(0);
+ Assertions.assertEquals("title", rockNode.getField());
+ Assertions.assertEquals("rock", rockNode.getValue());
+ Assertions.assertTrue(rockNode.isExplicitField());
+
+ QsNode notNode = root.getChildren().get(1);
+ Assertions.assertEquals(QsClauseType.NOT, notNode.getType());
+ }
+
+ // ============ Tests for MATCH_ALL_DOCS in multi-field mode ============
@Test
public void testMultiFieldMatchAllDocsBestFieldsLuceneMode() {
// Test: "*" with best_fields + lucene mode should produce
MATCH_ALL_DOCS
diff --git a/regression-test/suites/search/test_search_boundary_cases.groovy
b/regression-test/suites/search/test_search_boundary_cases.groovy
index e29b5decb30..b7c3a931252 100644
--- a/regression-test/suites/search/test_search_boundary_cases.groovy
+++ b/regression-test/suites/search/test_search_boundary_cases.groovy
@@ -18,6 +18,9 @@
suite("test_search_boundary_cases") {
def tableName = "search_boundary_test"
+ // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy
testing.
+ sql """ set enable_common_expr_pushdown = true """
+
sql "DROP TABLE IF EXISTS ${tableName}"
// Create test table for boundary and edge cases
diff --git
a/regression-test/suites/search/test_search_default_field_operator.groovy
b/regression-test/suites/search/test_search_default_field_operator.groovy
index 23082586235..89d07a794be 100644
--- a/regression-test/suites/search/test_search_default_field_operator.groovy
+++ b/regression-test/suites/search/test_search_default_field_operator.groovy
@@ -18,6 +18,9 @@
suite("test_search_default_field_operator") {
def tableName = "search_enhanced_test"
+ // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy
testing.
+ sql """ set enable_common_expr_pushdown = true """
+
sql "DROP TABLE IF EXISTS ${tableName}"
// Create table with inverted indexes
diff --git a/regression-test/suites/search/test_search_dsl_operators.groovy
b/regression-test/suites/search/test_search_dsl_operators.groovy
index 37ac49abf55..7415dfab10a 100644
--- a/regression-test/suites/search/test_search_dsl_operators.groovy
+++ b/regression-test/suites/search/test_search_dsl_operators.groovy
@@ -38,6 +38,9 @@
suite("test_search_dsl_operators") {
def tableName = "search_dsl_operators_test"
+ // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy
testing.
+ sql """ set enable_common_expr_pushdown = true """
+
sql "DROP TABLE IF EXISTS ${tableName}"
// Create table with inverted indexes
diff --git a/regression-test/suites/search/test_search_escape.groovy
b/regression-test/suites/search/test_search_escape.groovy
index 5414455d9d9..18e999e2767 100644
--- a/regression-test/suites/search/test_search_escape.groovy
+++ b/regression-test/suites/search/test_search_escape.groovy
@@ -32,6 +32,9 @@
suite("test_search_escape") {
def tableName = "search_escape_test"
+ // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy
testing.
+ sql """ set enable_common_expr_pushdown = true """
+
sql "DROP TABLE IF EXISTS ${tableName}"
// Create table with inverted indexes
diff --git a/regression-test/suites/search/test_search_exact_basic.groovy
b/regression-test/suites/search/test_search_exact_basic.groovy
index cf5701ac98f..0c9c368c340 100644
--- a/regression-test/suites/search/test_search_exact_basic.groovy
+++ b/regression-test/suites/search/test_search_exact_basic.groovy
@@ -18,6 +18,9 @@
suite("test_search_exact_basic") {
def tableName = "exact_basic_test"
+ // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy
testing.
+ sql """ set enable_common_expr_pushdown = true """
+
sql "DROP TABLE IF EXISTS ${tableName}"
// Simple table with basic index
diff --git a/regression-test/suites/search/test_search_exact_lowercase.groovy
b/regression-test/suites/search/test_search_exact_lowercase.groovy
index 9d1b3756cbc..8389d7e72ed 100644
--- a/regression-test/suites/search/test_search_exact_lowercase.groovy
+++ b/regression-test/suites/search/test_search_exact_lowercase.groovy
@@ -18,6 +18,9 @@
suite("test_search_exact_lowercase") {
def tableName = "exact_lowercase_test"
+ // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy
testing.
+ sql """ set enable_common_expr_pushdown = true """
+
sql "DROP TABLE IF EXISTS ${tableName}"
// EXACT on mixed indexes: prefers untokenized, but untokenized index
doesn't support lowercase
diff --git a/regression-test/suites/search/test_search_exact_match.groovy
b/regression-test/suites/search/test_search_exact_match.groovy
index 307963b19b1..caf55679375 100644
--- a/regression-test/suites/search/test_search_exact_match.groovy
+++ b/regression-test/suites/search/test_search_exact_match.groovy
@@ -18,6 +18,9 @@
suite("test_search_exact_match") {
def tableName = "search_exact_test_table"
+ // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy
testing.
+ sql """ set enable_common_expr_pushdown = true """
+
sql "DROP TABLE IF EXISTS ${tableName}"
// Create test table with different index configurations
diff --git a/regression-test/suites/search/test_search_exact_multi_index.groovy
b/regression-test/suites/search/test_search_exact_multi_index.groovy
index ab361dc45c0..1230b53aa4d 100644
--- a/regression-test/suites/search/test_search_exact_multi_index.groovy
+++ b/regression-test/suites/search/test_search_exact_multi_index.groovy
@@ -18,6 +18,9 @@
suite("test_search_exact_multi_index") {
def tableName = "exact_multi_index_test"
+ // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy
testing.
+ sql """ set enable_common_expr_pushdown = true """
+
sql "DROP TABLE IF EXISTS ${tableName}"
// Table with multiple indexes on the same column
diff --git a/regression-test/suites/search/test_search_field_group_query.groovy
b/regression-test/suites/search/test_search_field_group_query.groovy
new file mode 100644
index 00000000000..b352d6d1cc7
--- /dev/null
+++ b/regression-test/suites/search/test_search_field_group_query.groovy
@@ -0,0 +1,205 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+/**
+ * Tests for field-grouped query syntax in search() function.
+ *
+ * Supports ES query_string field-grouped syntax: field:(term1 OR term2)
+ * All terms inside the parentheses inherit the field prefix.
+ *
+ * Equivalent transformations:
+ * title:(rock OR jazz) → (title:rock OR title:jazz)
+ * title:(rock jazz) AND:and → (+title:rock +title:jazz)
+ * title:(rock OR jazz) AND music → (title:rock OR title:jazz) AND music
+ */
+suite("test_search_field_group_query") {
+ def tableName = "search_field_group_test"
+
+ // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy
testing.
+ // When false, search() expressions are not pushed to the inverted index
evaluation
+ // path, causing "SearchExpr should not be executed without inverted
index" errors.
+ sql """ set enable_common_expr_pushdown = true """
+
+ sql "DROP TABLE IF EXISTS ${tableName}"
+
+ sql """
+ CREATE TABLE ${tableName} (
+ id INT,
+ title VARCHAR(255),
+ content TEXT,
+ category VARCHAR(100),
+ INDEX idx_title (title) USING INVERTED PROPERTIES("parser" =
"english"),
+ INDEX idx_content (content) USING INVERTED PROPERTIES("parser" =
"english"),
+ INDEX idx_category (category) USING INVERTED
+ ) ENGINE=OLAP
+ DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES ("replication_allocation" = "tag.location.default: 1")
+ """
+
+ sql """INSERT INTO ${tableName} VALUES
+ (1, 'rock music history', 'The history of rock and roll music',
'Music'),
+ (2, 'jazz music theory', 'Jazz harmony and improvisation theory',
'Music'),
+ (3, 'classical music guide', 'Guide to classical music composers',
'Music'),
+ (4, 'python programming', 'Python language tutorial for beginners',
'Tech'),
+ (5, 'rock climbing tips', 'Tips and techniques for rock climbing',
'Sports'),
+ (6, 'jazz and blues fusion', 'The fusion of jazz and blues in modern
music', 'Music'),
+ (7, 'machine learning', 'Introduction to machine learning
algorithms', 'Tech'),
+ (8, 'rock and jazz review', 'Review of rock and jazz music
festivals', 'Music')
+ """
+
+ sql "sync"
+
+ // === Basic field-grouped OR query ===
+
+ // title:(rock OR jazz) should match rows with "rock" or "jazz" in title
+ def res1 = sql """
+ SELECT id FROM ${tableName}
+ WHERE search('title:(rock OR jazz)', '{"default_operator":"or"}')
+ ORDER BY id
+ """
+ // rows 1 (rock music history), 2 (jazz music theory), 5 (rock climbing
tips),
+ // 6 (jazz and blues fusion), 8 (rock and jazz review)
+ assertEquals([[1], [2], [5], [6], [8]], res1)
+
+ // === Field-grouped query vs equivalent expanded query ===
+
+ // title:(rock OR jazz) should give same results as explicit (title:rock
OR title:jazz)
+ def res2a = sql """
+ SELECT id FROM ${tableName}
+ WHERE search('title:(rock OR jazz)', '{"default_operator":"or"}')
+ ORDER BY id
+ """
+ def res2b = sql """
+ SELECT id FROM ${tableName}
+ WHERE search('title:rock OR title:jazz', '{"default_operator":"or"}')
+ ORDER BY id
+ """
+ assertEquals(res2a, res2b)
+
+ // === Field-grouped AND (implicit) ===
+
+ // title:(rock jazz) with default_operator:AND → both "rock" AND "jazz"
must be in title
+ def res3 = sql """
+ SELECT id FROM ${tableName}
+ WHERE search('title:(rock jazz)', '{"default_operator":"and"}')
+ ORDER BY id
+ """
+ // Only row 8 has both "rock" and "jazz" in title
+ assertEquals([[8]], res3)
+
+ // === Field-grouped query combined with bare query ===
+
+ // title:(rock OR jazz) AND music - explicit title terms + bare "music" on
default field
+ def res4 = sql """
+ SELECT id FROM ${tableName}
+ WHERE search('title:(rock OR jazz) AND music',
'{"default_field":"title","default_operator":"and"}')
+ ORDER BY id
+ """
+ // Must have "rock" or "jazz" in title AND "music" in title
+ // Row 1: title="rock music history" → has "rock" and "music" ✓
+ // Row 2: title="jazz music theory" → has "jazz" and "music" ✓
+ // Row 8: title="rock and jazz review" → has "rock" and "jazz" but no
"music" in title ✗
+ // Row 5: title="rock climbing tips" → has "rock" but no "music" ✗
+ assertEquals([[1], [2]], res4)
+
+ // === Field-grouped query in multi-field mode ===
+
+ // title:(rock OR jazz) with fields=[title,content]
+ // Explicit title:(rock OR jazz) should NOT expand to content field
+ def res5a = sql """
+ SELECT id FROM ${tableName}
+ WHERE search('title:(rock OR jazz)',
'{"fields":["title","content"],"type":"cross_fields"}')
+ ORDER BY id
+ """
+ // Only rows where title contains "rock" or "jazz"
+ // NOT rows where only content has those terms (row 3 has "rock and roll"
in content)
+ def res5b = sql """
+ SELECT id FROM ${tableName}
+ WHERE search('title:rock OR title:jazz',
'{"fields":["title","content"],"type":"cross_fields"}')
+ ORDER BY id
+ """
+ assertEquals(res5a, res5b)
+
+ // Rows 1,2,5,6,8 have rock/jazz in title; content-only matches should not
appear
+ assertEquals(true, res5a.size() >= 1)
+ // Row 4 has "python" in title, so it should NOT appear
+ assert !res5a.collect { it[0] }.contains(4)
+ // Row 7 has "machine learning", so it should NOT appear
+ assert !res5a.collect { it[0] }.contains(7)
+
+ // === Phrase inside field group ===
+
+ // title:("rock and") - phrase query inside group
+ def res6 = sql """
+ SELECT id FROM ${tableName}
+ WHERE search('title:("rock and")', '{"default_operator":"or"}')
+ ORDER BY id
+ """
+ // Row 8: "rock and jazz review" → contains "rock and" ✓
+ assert res6.collect { it[0] }.contains(8)
+
+ // === Field-grouped query in Lucene mode ===
+
+ // title:(rock OR jazz) in lucene mode
+ def res7 = sql """
+ SELECT id FROM ${tableName}
+ WHERE search('title:(rock OR jazz)', '{"mode":"lucene"}')
+ ORDER BY id
+ """
+ // Should match rows with "rock" or "jazz" in title (SHOULD semantics)
+ assertEquals([[1], [2], [5], [6], [8]], res7)
+
+ // title:(rock AND jazz) in lucene mode
+ def res8 = sql """
+ SELECT id FROM ${tableName}
+ WHERE search('title:(rock AND jazz)', '{"mode":"lucene"}')
+ ORDER BY id
+ """
+ // Must have both "rock" AND "jazz" in title
+ assertEquals([[8]], res8)
+
+ // === Field-grouped combined with explicit field query ===
+
+ // title:(rock OR jazz) AND category:Music
+ def res9 = sql """
+ SELECT id FROM ${tableName}
+ WHERE search('title:(rock OR jazz) AND category:Music',
'{"default_operator":"and"}')
+ ORDER BY id
+ """
+ // Must have (rock or jazz in title) AND category=Music
+ // Row 1: rock music history, Music ✓
+ // Row 2: jazz music theory, Music ✓
+ // Row 5: rock climbing tips, Sports ✗
+ // Row 6: jazz and blues fusion, Music ✓
+ // Row 8: rock and jazz review, Music ✓
+ assertEquals([[1], [2], [6], [8]], res9)
+
+ // === Verify it was previously broken (would have been a syntax error) ===
+ // This verifies the fix: parsing title:(rock OR jazz) should not throw
+ try {
+ def resSyntax = sql """
+ SELECT COUNT(*) FROM ${tableName}
+ WHERE search('title:(rock OR jazz)', '{"default_operator":"or"}')
+ """
+ assert resSyntax[0][0] >= 0 : "Should parse and execute without error"
+ } catch (Exception e) {
+ throw new AssertionError("title:(rock OR jazz) syntax should be
supported: " + e.message)
+ }
+
+ sql "DROP TABLE IF EXISTS ${tableName}"
+}
diff --git a/regression-test/suites/search/test_search_lucene_mode.groovy
b/regression-test/suites/search/test_search_lucene_mode.groovy
index 8e95a27a377..f971d8a9729 100644
--- a/regression-test/suites/search/test_search_lucene_mode.groovy
+++ b/regression-test/suites/search/test_search_lucene_mode.groovy
@@ -33,6 +33,9 @@
suite("test_search_lucene_mode") {
def tableName = "search_lucene_mode_test"
+ // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy
testing.
+ sql """ set enable_common_expr_pushdown = true """
+
sql "DROP TABLE IF EXISTS ${tableName}"
// Create table with inverted indexes
diff --git a/regression-test/suites/search/test_search_multi_field.groovy
b/regression-test/suites/search/test_search_multi_field.groovy
index bd55a874f41..ca1c97eef5a 100644
--- a/regression-test/suites/search/test_search_multi_field.groovy
+++ b/regression-test/suites/search/test_search_multi_field.groovy
@@ -33,6 +33,9 @@
suite("test_search_multi_field") {
def tableName = "search_multi_field_test"
+ // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy
testing.
+ sql """ set enable_common_expr_pushdown = true """
+
sql "DROP TABLE IF EXISTS ${tableName}"
// Create table with inverted indexes on multiple fields
diff --git a/regression-test/suites/search/test_search_null_regression.groovy
b/regression-test/suites/search/test_search_null_regression.groovy
index 742f86959a1..70666341662 100644
--- a/regression-test/suites/search/test_search_null_regression.groovy
+++ b/regression-test/suites/search/test_search_null_regression.groovy
@@ -18,6 +18,9 @@
suite("test_search_null_regression") {
def tableName = "search_null_regression_test"
+ // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy
testing.
+ sql """ set enable_common_expr_pushdown = true """
+
sql "DROP TABLE IF EXISTS ${tableName}"
// Create test table that reproduces the original bug scenarios
diff --git a/regression-test/suites/search/test_search_null_semantics.groovy
b/regression-test/suites/search/test_search_null_semantics.groovy
index 9e18e063cf9..1c49e350bf3 100644
--- a/regression-test/suites/search/test_search_null_semantics.groovy
+++ b/regression-test/suites/search/test_search_null_semantics.groovy
@@ -18,6 +18,9 @@
suite("test_search_null_semantics") {
def tableName = "search_null_test"
+ // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy
testing.
+ sql """ set enable_common_expr_pushdown = true """
+
sql "DROP TABLE IF EXISTS ${tableName}"
// Create test table with inverted index and NULL values
diff --git a/regression-test/suites/search/test_search_regexp_lowercase.groovy
b/regression-test/suites/search/test_search_regexp_lowercase.groovy
index 957027c2610..7151ac1c10b 100644
--- a/regression-test/suites/search/test_search_regexp_lowercase.groovy
+++ b/regression-test/suites/search/test_search_regexp_lowercase.groovy
@@ -22,6 +22,9 @@
suite("test_search_regexp_lowercase") {
def tableName = "search_regexp_lowercase_test"
+ // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy
testing.
+ sql """ set enable_common_expr_pushdown = true """
+
sql "DROP TABLE IF EXISTS ${tableName}"
sql """
diff --git
a/regression-test/suites/search/test_search_variant_dual_index_reader.groovy
b/regression-test/suites/search/test_search_variant_dual_index_reader.groovy
index 6e1e297af92..6e53370381f 100644
--- a/regression-test/suites/search/test_search_variant_dual_index_reader.groovy
+++ b/regression-test/suites/search/test_search_variant_dual_index_reader.groovy
@@ -39,6 +39,8 @@ suite("test_search_variant_dual_index_reader") {
sql """ set enable_match_without_inverted_index = false """
sql """ set enable_common_expr_pushdown = true """
sql """ set default_variant_enable_typed_paths_to_sparse = false """
+ // Pin doc_mode to false to prevent CI flakiness from fuzzy testing.
+ sql """ set default_variant_enable_doc_mode = false """
sql "DROP TABLE IF EXISTS ${tableName}"
diff --git
a/regression-test/suites/search/test_search_variant_subcolumn_analyzer.groovy
b/regression-test/suites/search/test_search_variant_subcolumn_analyzer.groovy
index d14cf15f7a3..61c7c34c6e9 100644
---
a/regression-test/suites/search/test_search_variant_subcolumn_analyzer.groovy
+++
b/regression-test/suites/search/test_search_variant_subcolumn_analyzer.groovy
@@ -34,6 +34,11 @@ suite("test_search_variant_subcolumn_analyzer") {
sql """ set enable_match_without_inverted_index = false """
sql """ set enable_common_expr_pushdown = true """
sql """ set default_variant_enable_typed_paths_to_sparse = false """
+ // Pin doc_mode to false to prevent CI flakiness from fuzzy testing.
+ // When default_variant_enable_doc_mode=true (randomly set by fuzzy
testing),
+ // variant subcolumns are stored in document mode, causing inverted index
+ // iterators to be unavailable in BE for search() evaluation.
+ sql """ set default_variant_enable_doc_mode = false """
sql "DROP TABLE IF EXISTS ${tableName}"
diff --git
a/regression-test/suites/search/test_search_vs_match_consistency.groovy
b/regression-test/suites/search/test_search_vs_match_consistency.groovy
index e39268838e5..ac4cd1f802b 100644
--- a/regression-test/suites/search/test_search_vs_match_consistency.groovy
+++ b/regression-test/suites/search/test_search_vs_match_consistency.groovy
@@ -18,6 +18,9 @@
suite("test_search_vs_match_consistency") {
def tableName = "search_match_consistency_test"
+ // Pin enable_common_expr_pushdown to prevent CI flakiness from fuzzy
testing.
+ sql """ set enable_common_expr_pushdown = true """
+
sql "DROP TABLE IF EXISTS ${tableName}"
// Create test table similar to wikipedia structure
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]