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); + } }