diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Search.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Search.java
index 515ab6e37ec881..f89af38cc22208 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Search.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/Search.java
@@ -33,12 +33,23 @@
/**
* ScalarFunction 'search' - simplified architecture similar to MultiMatch.
* Handles DSL parsing and generates SearchPredicate during translation.
+ *
+ * Supports 1-3 parameters:
+ * - search(dsl_string): Traditional usage
+ * - search(dsl_string, default_field): Simplified syntax with default field
+ * - search(dsl_string, default_field, default_operator): Full control over expansion
*/
public class Search extends ScalarFunction
implements ExplicitlyCastableSignature, AlwaysNotNullable {
public static final List SIGNATURES = ImmutableList.of(
- FunctionSignature.ret(BooleanType.INSTANCE).varArgs(StringType.INSTANCE)
+ // Original signature: search(dsl_string)
+ FunctionSignature.ret(BooleanType.INSTANCE).args(StringType.INSTANCE),
+ // With default field: search(dsl_string, default_field)
+ FunctionSignature.ret(BooleanType.INSTANCE).args(StringType.INSTANCE, StringType.INSTANCE),
+ // With default field and operator: search(dsl_string, default_field, default_operator)
+ FunctionSignature.ret(BooleanType.INSTANCE).args(StringType.INSTANCE, StringType.INSTANCE,
+ StringType.INSTANCE)
);
public Search(Expression... varArgs) {
@@ -51,7 +62,8 @@ private Search(ScalarFunctionParams functionParams) {
@Override
public Search withChildren(List children) {
- Preconditions.checkArgument(children.size() >= 1);
+ Preconditions.checkArgument(children.size() >= 1 && children.size() <= 3,
+ "search() requires 1-3 arguments");
return new Search(getFunctionParams(children));
}
@@ -76,13 +88,41 @@ public String getDslString() {
return dslArg.toString();
}
+ /**
+ * Get default field from second argument (optional)
+ */
+ public String getDefaultField() {
+ if (children().size() < 2) {
+ return null;
+ }
+ Expression fieldArg = child(1);
+ if (fieldArg instanceof StringLikeLiteral) {
+ return ((StringLikeLiteral) fieldArg).getStringValue();
+ }
+ return fieldArg.toString();
+ }
+
+ /**
+ * Get default operator from third argument (optional)
+ */
+ public String getDefaultOperator() {
+ if (children().size() < 3) {
+ return null;
+ }
+ Expression operatorArg = child(2);
+ if (operatorArg instanceof StringLikeLiteral) {
+ return ((StringLikeLiteral) operatorArg).getStringValue();
+ }
+ return operatorArg.toString();
+ }
+
/**
* Get parsed DSL plan - deferred to translation phase
* This will be handled by SearchPredicate during ExpressionTranslator.visitSearch()
*/
public SearchDslParser.QsPlan getQsPlan() {
// Lazy evaluation will be handled in SearchPredicate
- return SearchDslParser.parseDsl(getDslString());
+ return SearchDslParser.parseDsl(getDslString(), getDefaultField(), getDefaultOperator());
}
@Override
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 e9c79acf9dc3e3..8dfd9febb68536 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
@@ -61,13 +61,32 @@ public class SearchDslParser {
* Parse DSL string and return intermediate representation
*/
public static QsPlan parseDsl(String dsl) {
+ return parseDsl(dsl, null, null);
+ }
+
+ /**
+ * Parse DSL string with default field and operator support
+ *
+ * @param dsl DSL query string
+ * @param defaultField Default field name when DSL doesn't specify field (optional)
+ * @param defaultOperator Default operator ("and" or "or") for multi-term queries (optional, defaults to "or")
+ * @return Parsed QsPlan
+ */
+ public static QsPlan parseDsl(String dsl, String defaultField, String defaultOperator) {
if (dsl == null || dsl.trim().isEmpty()) {
return new QsPlan(new QsNode(QsClauseType.TERM, "error", "empty_dsl"), new ArrayList<>());
}
+ // Expand simplified DSL if default field is provided
+ String expandedDsl = dsl;
+ if (defaultField != null && !defaultField.trim().isEmpty()) {
+ expandedDsl = expandSimplifiedDsl(dsl.trim(), defaultField.trim(),
+ normalizeDefaultOperator(defaultOperator));
+ }
+
try {
// Create ANTLR lexer and parser
- SearchLexer lexer = new SearchLexer(new ANTLRInputStream(dsl));
+ SearchLexer lexer = new SearchLexer(new ANTLRInputStream(expandedDsl));
CommonTokenStream tokens = new CommonTokenStream(lexer);
SearchParser parser = new SearchParser(tokens);
@@ -107,11 +126,271 @@ public void syntaxError(org.antlr.v4.runtime.Recognizer, ?> recognizer,
return new QsPlan(root, bindings);
} catch (Exception e) {
- LOG.error("Failed to parse search DSL: '{}'", dsl, e);
+ LOG.error("Failed to parse search DSL: '{}' (expanded: '{}')", dsl, expandedDsl, e);
throw new RuntimeException("Invalid search DSL syntax: " + dsl + ". Error: " + e.getMessage(), e);
}
}
+ /**
+ * Normalize default operator to lowercase "and" or "or"
+ */
+ private static String normalizeDefaultOperator(String operator) {
+ if (operator == null || operator.trim().isEmpty()) {
+ return "or"; // Default to OR
+ }
+ String normalized = operator.trim().toLowerCase();
+ if ("and".equals(normalized) || "or".equals(normalized)) {
+ return normalized;
+ }
+ throw new IllegalArgumentException("Invalid default operator: " + operator
+ + ". Must be 'and' or 'or'");
+ }
+
+ /**
+ * Expand simplified DSL to full DSL format
+ *
+ * Examples:
+ * - "foo bar" + field="tags" + operator="and" → "tags:ALL(foo bar)"
+ * - "foo* bar*" + field="tags" + operator="and" → "tags:foo* AND tags:bar*"
+ * - "foo OR bar" + field="tags" → "tags:foo OR tags:bar"
+ * - "EXACT(foo bar)" + field="tags" → "tags:EXACT(foo bar)"
+ *
+ * @param dsl Simple DSL string
+ * @param defaultField Default field name
+ * @param defaultOperator "and" or "or"
+ * @return Expanded full DSL
+ */
+ private static String expandSimplifiedDsl(String dsl, String defaultField, String defaultOperator) {
+ // 1. If DSL already contains field names (colon), return as-is
+ if (containsFieldReference(dsl)) {
+ return dsl;
+ }
+
+ // 2. Check if DSL starts with a function keyword (EXACT, ANY, ALL, IN)
+ if (startsWithFunction(dsl)) {
+ return defaultField + ":" + dsl;
+ }
+
+ // 3. Check for explicit boolean operators in DSL
+ if (containsExplicitOperators(dsl)) {
+ return addFieldPrefixToOperatorExpression(dsl, defaultField);
+ }
+
+ // 4. Tokenize and analyze terms
+ List terms = tokenizeDsl(dsl);
+ if (terms.isEmpty()) {
+ return defaultField + ":" + dsl;
+ }
+
+ // 5. Single term - simple case
+ if (terms.size() == 1) {
+ return defaultField + ":" + terms.get(0);
+ }
+
+ // 6. Multiple terms - check for wildcards
+ boolean hasWildcard = terms.stream().anyMatch(SearchDslParser::containsWildcard);
+
+ if (hasWildcard) {
+ // Wildcards cannot be tokenized - must create separate field queries
+ String operator = "and".equals(defaultOperator) ? " AND " : " OR ";
+ return terms.stream()
+ .map(term -> defaultField + ":" + term)
+ .collect(java.util.stream.Collectors.joining(operator));
+ } else {
+ // Regular multi-term query - use ANY/ALL
+ String clauseType = "and".equals(defaultOperator) ? "ALL" : "ANY";
+ return defaultField + ":" + clauseType + "(" + dsl + ")";
+ }
+ }
+
+ /**
+ * Check if DSL contains field references (has colon not in quoted strings)
+ */
+ private static boolean containsFieldReference(String dsl) {
+ boolean inQuotes = false;
+ boolean inRegex = false;
+ for (int i = 0; i < dsl.length(); i++) {
+ char c = dsl.charAt(i);
+ if (c == '"' && (i == 0 || dsl.charAt(i - 1) != '\\')) {
+ inQuotes = !inQuotes;
+ } else if (c == '/' && !inQuotes) {
+ inRegex = !inRegex;
+ } else if (c == ':' && !inQuotes && !inRegex) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Check if DSL starts with function keywords
+ */
+ private static boolean startsWithFunction(String dsl) {
+ String upper = dsl.toUpperCase();
+ return upper.startsWith("EXACT(")
+ || upper.startsWith("ANY(")
+ || upper.startsWith("ALL(")
+ || upper.startsWith("IN(");
+ }
+
+ /**
+ * Check if DSL contains explicit boolean operators (AND/OR/NOT)
+ */
+ private static boolean containsExplicitOperators(String dsl) {
+ // Look for standalone AND/OR/NOT keywords (not part of field names)
+ String upper = dsl.toUpperCase();
+ return upper.matches(".*\\s+(AND|OR)\\s+.*")
+ || upper.matches("^NOT\\s+.*")
+ || upper.matches(".*\\s+NOT\\s+.*");
+ }
+
+ /**
+ * Add field prefix to expressions with explicit operators
+ * Example: "foo AND bar" → "field:foo AND field:bar"
+ */
+ private static String addFieldPrefixToOperatorExpression(String dsl, String defaultField) {
+ StringBuilder result = new StringBuilder();
+ StringBuilder currentTerm = new StringBuilder();
+ int i = 0;
+
+ while (i < dsl.length()) {
+ // Skip whitespace
+ while (i < dsl.length() && Character.isWhitespace(dsl.charAt(i))) {
+ i++;
+ }
+ if (i >= dsl.length()) {
+ break;
+ }
+
+ // Try to match operators
+ String remaining = dsl.substring(i);
+ String upperRemaining = remaining.toUpperCase();
+
+ if (upperRemaining.startsWith("AND ") || upperRemaining.startsWith("AND\t")
+ || (upperRemaining.equals("AND") && i + 3 >= dsl.length())) {
+ // Found AND operator
+ if (currentTerm.length() > 0) {
+ if (result.length() > 0) {
+ result.append(" ");
+ }
+ result.append(defaultField).append(":").append(currentTerm.toString().trim());
+ currentTerm.setLength(0);
+ }
+ if (result.length() > 0) {
+ result.append(" ");
+ }
+ result.append(dsl.substring(i, i + 3)); // Preserve original case
+ i += 3;
+ continue;
+ } else if (upperRemaining.startsWith("OR ") || upperRemaining.startsWith("OR\t")
+ || (upperRemaining.equals("OR") && i + 2 >= dsl.length())) {
+ // Found OR operator
+ if (currentTerm.length() > 0) {
+ if (result.length() > 0) {
+ result.append(" ");
+ }
+ result.append(defaultField).append(":").append(currentTerm.toString().trim());
+ currentTerm.setLength(0);
+ }
+ if (result.length() > 0) {
+ result.append(" ");
+ }
+ result.append(dsl.substring(i, i + 2)); // Preserve original case
+ i += 2;
+ continue;
+ } else if (upperRemaining.startsWith("NOT ") || upperRemaining.startsWith("NOT\t")
+ || (upperRemaining.equals("NOT") && i + 3 >= dsl.length())) {
+ // Found NOT operator
+ if (currentTerm.length() > 0) {
+ if (result.length() > 0) {
+ result.append(" ");
+ }
+ result.append(defaultField).append(":").append(currentTerm.toString().trim());
+ currentTerm.setLength(0);
+ }
+ if (result.length() > 0) {
+ result.append(" ");
+ }
+ result.append(dsl.substring(i, i + 3)); // Preserve original case
+ i += 3;
+ continue;
+ }
+
+ // Not an operator, accumulate term
+ currentTerm.append(dsl.charAt(i));
+ i++;
+ }
+
+ // Add last term
+ if (currentTerm.length() > 0) {
+ if (result.length() > 0) {
+ result.append(" ");
+ }
+ result.append(defaultField).append(":").append(currentTerm.toString().trim());
+ }
+
+ return result.toString().trim();
+ }
+
+ /**
+ * Tokenize DSL into terms (split by whitespace, respecting quotes and functions)
+ */
+ private static List tokenizeDsl(String dsl) {
+ List terms = new ArrayList<>();
+ StringBuilder currentTerm = new StringBuilder();
+ boolean inQuotes = false;
+ boolean inParens = false;
+ int parenDepth = 0;
+
+ for (int i = 0; i < dsl.length(); i++) {
+ char c = dsl.charAt(i);
+
+ if (c == '"' && (i == 0 || dsl.charAt(i - 1) != '\\')) {
+ inQuotes = !inQuotes;
+ currentTerm.append(c);
+ } else if (c == '(' && !inQuotes) {
+ parenDepth++;
+ inParens = true;
+ currentTerm.append(c);
+ } else if (c == ')' && !inQuotes) {
+ parenDepth--;
+ if (parenDepth == 0) {
+ inParens = false;
+ }
+ currentTerm.append(c);
+ } else if (Character.isWhitespace(c) && !inQuotes && !inParens) {
+ // End of term
+ if (currentTerm.length() > 0) {
+ terms.add(currentTerm.toString());
+ currentTerm = new StringBuilder();
+ }
+ } else {
+ currentTerm.append(c);
+ }
+ }
+
+ // Add last term
+ if (currentTerm.length() > 0) {
+ terms.add(currentTerm.toString());
+ }
+
+ return terms;
+ }
+
+ /**
+ * Check if a term contains wildcard characters (* or ?)
+ */
+ private static boolean containsWildcard(String term) {
+ // Ignore wildcards in quoted strings or regex
+ if (term.startsWith("\"") && term.endsWith("\"")) {
+ return false;
+ }
+ if (term.startsWith("/") && term.endsWith("/")) {
+ return false;
+ }
+ return term.contains("*") || term.contains("?");
+ }
+
/**
* Clause types supported
*/
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 66e57e77fcc89f..eb1bf3f5d3a52c 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
@@ -272,4 +272,309 @@ public void testQuotedFieldNames() {
Assertions.assertEquals(1, plan.fieldBindings.size());
Assertions.assertEquals("field name", plan.fieldBindings.get(0).fieldName);
}
+
+ // ============ Tests for Default Field and Operator Support ============
+
+ @Test
+ public void testDefaultFieldWithSimpleTerm() {
+ // Test: "foo" + field="tags" → "tags:foo"
+ String dsl = "foo";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", null);
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(QsClauseType.TERM, plan.root.type);
+ Assertions.assertEquals("tags", plan.root.field);
+ Assertions.assertEquals("foo", plan.root.value);
+ Assertions.assertEquals(1, plan.fieldBindings.size());
+ Assertions.assertEquals("tags", plan.fieldBindings.get(0).fieldName);
+ }
+
+ @Test
+ public void testDefaultFieldWithMultiTermAnd() {
+ // Test: "foo bar" + field="tags" + operator="and" → "tags:ALL(foo bar)"
+ String dsl = "foo bar";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", "and");
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(QsClauseType.ALL, plan.root.type);
+ Assertions.assertEquals("tags", plan.root.field);
+ Assertions.assertEquals("foo bar", plan.root.value);
+ }
+
+ @Test
+ public void testDefaultFieldWithMultiTermOr() {
+ // Test: "foo bar" + field="tags" + operator="or" → "tags:ANY(foo bar)"
+ String dsl = "foo bar";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", "or");
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(QsClauseType.ANY, plan.root.type);
+ Assertions.assertEquals("tags", plan.root.field);
+ Assertions.assertEquals("foo bar", plan.root.value);
+ }
+
+ @Test
+ public void testDefaultFieldWithMultiTermDefaultOr() {
+ // Test: "foo bar" + field="tags" (no operator, defaults to OR) → "tags:ANY(foo bar)"
+ String dsl = "foo bar";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", null);
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(QsClauseType.ANY, plan.root.type);
+ Assertions.assertEquals("tags", plan.root.field);
+ Assertions.assertEquals("foo bar", plan.root.value);
+ }
+
+ @Test
+ public void testDefaultFieldWithWildcardSingleTerm() {
+ // Test: "foo*" + field="tags" → "tags:foo*"
+ String dsl = "foo*";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", null);
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(QsClauseType.PREFIX, plan.root.type);
+ Assertions.assertEquals("tags", plan.root.field);
+ Assertions.assertEquals("foo*", plan.root.value);
+ }
+
+ @Test
+ public void testDefaultFieldWithWildcardMultiTermAnd() {
+ // Test: "foo* bar*" + field="tags" + operator="and" → "tags:foo* AND tags:bar*"
+ String dsl = "foo* bar*";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", "and");
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(QsClauseType.AND, plan.root.type);
+ Assertions.assertEquals(2, plan.root.children.size());
+
+ QsNode firstChild = plan.root.children.get(0);
+ Assertions.assertEquals(QsClauseType.PREFIX, firstChild.type);
+ Assertions.assertEquals("tags", firstChild.field);
+ Assertions.assertEquals("foo*", firstChild.value);
+
+ QsNode secondChild = plan.root.children.get(1);
+ Assertions.assertEquals(QsClauseType.PREFIX, secondChild.type);
+ Assertions.assertEquals("tags", secondChild.field);
+ Assertions.assertEquals("bar*", secondChild.value);
+ }
+
+ @Test
+ public void testDefaultFieldWithWildcardMultiTermOr() {
+ // Test: "foo* bar*" + field="tags" + operator="or" → "tags:foo* OR tags:bar*"
+ String dsl = "foo* bar*";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", "or");
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(QsClauseType.OR, plan.root.type);
+ Assertions.assertEquals(2, plan.root.children.size());
+ }
+
+ @Test
+ public void testDefaultFieldWithExplicitOperatorOverride() {
+ // Test: "foo OR bar" + field="tags" + operator="and" → explicit OR takes precedence
+ String dsl = "foo OR bar";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", "and");
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(QsClauseType.OR, plan.root.type);
+ Assertions.assertEquals(2, plan.root.children.size());
+
+ QsNode firstChild = plan.root.children.get(0);
+ Assertions.assertEquals("tags", firstChild.field);
+ Assertions.assertEquals("foo", firstChild.value);
+
+ QsNode secondChild = plan.root.children.get(1);
+ Assertions.assertEquals("tags", secondChild.field);
+ Assertions.assertEquals("bar", secondChild.value);
+ }
+
+ @Test
+ public void testDefaultFieldWithExplicitAndOperator() {
+ // Test: "foo AND bar" + field="tags" + operator="or" → explicit AND takes precedence
+ String dsl = "foo AND bar";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", "or");
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(QsClauseType.AND, plan.root.type);
+ Assertions.assertEquals(2, plan.root.children.size());
+ }
+
+ @Test
+ public void testDefaultFieldWithExactFunction() {
+ // Test: "EXACT(foo bar)" + field="tags" → "tags:EXACT(foo bar)"
+ String dsl = "EXACT(foo bar)";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", null);
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(QsClauseType.EXACT, plan.root.type);
+ Assertions.assertEquals("tags", plan.root.field);
+ Assertions.assertEquals("foo bar", plan.root.value);
+ }
+
+ @Test
+ public void testDefaultFieldWithAnyFunction() {
+ // Test: "ANY(foo bar)" + field="tags" → "tags:ANY(foo bar)"
+ String dsl = "ANY(foo bar)";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", null);
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(QsClauseType.ANY, plan.root.type);
+ Assertions.assertEquals("tags", plan.root.field);
+ Assertions.assertEquals("foo bar", plan.root.value);
+ }
+
+ @Test
+ public void testDefaultFieldWithAllFunction() {
+ // Test: "ALL(foo bar)" + field="tags" → "tags:ALL(foo bar)"
+ String dsl = "ALL(foo bar)";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", null);
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(QsClauseType.ALL, plan.root.type);
+ Assertions.assertEquals("tags", plan.root.field);
+ Assertions.assertEquals("foo bar", plan.root.value);
+ }
+
+ @Test
+ public void testDefaultFieldIgnoredWhenDslHasFieldReference() {
+ // Test: DSL with field reference should ignore default field
+ String dsl = "title:hello";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", "and");
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(QsClauseType.TERM, plan.root.type);
+ Assertions.assertEquals("title", plan.root.field); // Should be "title", not "tags"
+ Assertions.assertEquals("hello", plan.root.value);
+ }
+
+ @Test
+ public void testInvalidDefaultOperator() {
+ // Test: invalid operator should throw exception
+ String dsl = "foo bar";
+
+ IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, () -> {
+ SearchDslParser.parseDsl(dsl, "tags", "invalid");
+ });
+
+ Assertions.assertTrue(exception.getMessage().contains("Invalid default operator"));
+ Assertions.assertTrue(exception.getMessage().contains("Must be 'and' or 'or'"));
+ }
+
+ @Test
+ public void testDefaultOperatorCaseInsensitive() {
+ // Test: operator should be case-insensitive
+ String dsl = "foo bar";
+
+ // Test "AND"
+ QsPlan plan1 = SearchDslParser.parseDsl(dsl, "tags", "AND");
+ Assertions.assertEquals(QsClauseType.ALL, plan1.root.type);
+
+ // Test "Or"
+ QsPlan plan2 = SearchDslParser.parseDsl(dsl, "tags", "Or");
+ Assertions.assertEquals(QsClauseType.ANY, plan2.root.type);
+
+ // Test "aNd"
+ QsPlan plan3 = SearchDslParser.parseDsl(dsl, "tags", "aNd");
+ Assertions.assertEquals(QsClauseType.ALL, plan3.root.type);
+ }
+
+ @Test
+ public void testDefaultFieldWithComplexWildcard() {
+ // Test: "*foo*" (middle wildcard) + field="tags"
+ String dsl = "*foo*";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", null);
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(QsClauseType.WILDCARD, plan.root.type);
+ Assertions.assertEquals("tags", plan.root.field);
+ Assertions.assertEquals("*foo*", plan.root.value);
+ }
+
+ @Test
+ public void testDefaultFieldWithMixedWildcards() {
+ // Test: "foo* *bar baz" (mixed wildcards and regular terms) + field="tags" + operator="and"
+ String dsl = "foo* bar baz";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", "and");
+
+ Assertions.assertNotNull(plan);
+ // Should create AND query because it contains wildcards
+ Assertions.assertEquals(QsClauseType.AND, plan.root.type);
+ Assertions.assertEquals(3, plan.root.children.size());
+ }
+
+ @Test
+ public void testDefaultFieldWithQuotedPhrase() {
+ // Test: quoted phrase should be treated as PHRASE
+ String dsl = "\"hello world\"";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", "and");
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(QsClauseType.PHRASE, plan.root.type);
+ Assertions.assertEquals("tags", plan.root.field);
+ Assertions.assertEquals("hello world", plan.root.value);
+ }
+
+ @Test
+ public void testDefaultFieldWithNotOperator() {
+ // Test: "NOT foo" + field="tags" → "NOT tags:foo"
+ String dsl = "NOT foo";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", null);
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(QsClauseType.NOT, plan.root.type);
+ Assertions.assertEquals(1, plan.root.children.size());
+
+ QsNode child = plan.root.children.get(0);
+ Assertions.assertEquals(QsClauseType.TERM, child.type);
+ Assertions.assertEquals("tags", child.field);
+ Assertions.assertEquals("foo", child.value);
+ }
+
+ @Test
+ public void testDefaultFieldWithEmptyString() {
+ // Test: empty default field should not expand DSL, causing parse error
+ // for incomplete DSL like "foo" (no field specified)
+ String dsl = "foo";
+
+ // This should throw an exception because "foo" alone is not valid DSL
+ RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> {
+ SearchDslParser.parseDsl(dsl, "", "and");
+ });
+
+ Assertions.assertTrue(exception.getMessage().contains("Invalid search DSL syntax"));
+ }
+
+ @Test
+ public void testDefaultFieldWithNullOperator() {
+ // Test: null operator should default to OR
+ String dsl = "foo bar";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", null);
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(QsClauseType.ANY, plan.root.type); // Defaults to OR/ANY
+ }
+
+ @Test
+ public void testDefaultFieldWithSingleWildcardTerm() {
+ // Test: single term with wildcard should not use ANY/ALL
+ String dsl = "f?o";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", "and");
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(QsClauseType.WILDCARD, plan.root.type);
+ Assertions.assertEquals("tags", plan.root.field);
+ Assertions.assertEquals("f?o", plan.root.value);
+ }
+
+ @Test
+ public void testDefaultFieldPreservesFieldBindings() {
+ // Test: field bindings should be correctly generated with default field
+ String dsl = "foo bar";
+ QsPlan plan = SearchDslParser.parseDsl(dsl, "tags", "and");
+
+ Assertions.assertNotNull(plan);
+ Assertions.assertEquals(1, plan.fieldBindings.size());
+ Assertions.assertEquals("tags", plan.fieldBindings.get(0).fieldName);
+ Assertions.assertEquals(0, plan.fieldBindings.get(0).slotIndex);
+ }
}