From 7a7a55f604178e9f93eb8ae43a001b92e0ba54e9 Mon Sep 17 00:00:00 2001 From: Randy Miller Date: Tue, 13 Oct 2026 19:41:32 -0400 Subject: [PATCH] Honor quotes on unfielded PPL search terms without whitespace A quoted free-text term in the search command, such as `search "foo-bar" source=logs`, was emitted to query_string without its quotes unless the value contained a space. The analyzer then split the value into tokens and default_operator=OR matched any one of them. The same over-match was fixed for fielded text comparisons in #5697, but unfielded literals were left on the legacy whitespace check. Emit a phrase for any user-quoted unfielded string, keeping the existing exception for a whitespace-free value with an unescaped wildcard. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Randy Miller --- .../sql/ast/expression/SearchLiteral.java | 17 ++++++- .../sql/ast/expression/SearchLiteralTest.java | 44 +++++++++++++++++++ docs/user/ppl/cmd/search.md | 2 + .../ppl/parser/AstSearchExpressionTest.java | 2 +- 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/org/opensearch/sql/ast/expression/SearchLiteral.java b/core/src/main/java/org/opensearch/sql/ast/expression/SearchLiteral.java index 1932e943bb0..116ce103fcc 100644 --- a/core/src/main/java/org/opensearch/sql/ast/expression/SearchLiteral.java +++ b/core/src/main/java/org/opensearch/sql/ast/expression/SearchLiteral.java @@ -32,7 +32,22 @@ public class SearchLiteral extends SearchExpression { @Override public String toQueryString(Function fieldTypeResolver) { - // Unfielded literal: no enclosing field, so no index type. Take the field-agnostic branch. + // Unfielded literal: no enclosing field, so no index type. The value is matched against + // index.query.default_field, which usually includes analyzed text, so the user's quoting is + // honored the same way as on a text field: a quoted value is a phrase even without whitespace. + // Otherwise a value the analyzer splits (`foo-bar`) becomes an OR of its tokens. On fields + // with a no-op analyzer the phrase and the bare term resolve to the same single term. + if (userQuoted + && literal instanceof Literal + && ((Literal) literal).getValue() instanceof String) { + String str = (String) ((Literal) literal).getValue(); + // Same exception as on text: a whitespace-free wildcard value stays unquoted so `*` and `?` + // keep their operator meaning. + boolean wildcardTerm = hasUnescapedWildcard(str) && !str.contains(" "); + if (!wildcardTerm) { + return quoted(str); + } + } return toQueryString((ExprType) null); } diff --git a/core/src/test/java/org/opensearch/sql/ast/expression/SearchLiteralTest.java b/core/src/test/java/org/opensearch/sql/ast/expression/SearchLiteralTest.java index c722916f339..384bed94858 100644 --- a/core/src/test/java/org/opensearch/sql/ast/expression/SearchLiteralTest.java +++ b/core/src/test/java/org/opensearch/sql/ast/expression/SearchLiteralTest.java @@ -197,6 +197,50 @@ void argless_toQueryString_matches_null_type_branch() { assertEquals(lit.toQueryString((ExprType) null), lit.toQueryString()); } + // ------------------------------------------------------------------------- + // UNFIELDED (free text): no index type, so honor the user's quoting as on text. + // ------------------------------------------------------------------------- + + /** + * A quoted free-text value the analyzer splits must not become an OR of tokens. These values hold + * no whitespace, so the legacy space check emitted them unquoted. + */ + @Test + void unfielded_quoted_value_the_analyzer_splits_emits_phrase() { + assertEquals("\"foo\\-bar\"", q("foo-bar").toQueryString()); + assertEquals("\"foo=bar\"", q("foo=bar").toQueryString()); + assertEquals("\"user@example.com\"", q("user@example.com").toQueryString()); + } + + @Test + void unfielded_quoted_with_whitespace_emits_phrase() { + assertEquals("\"foo bar\"", q("foo bar").toQueryString()); + assertEquals("\"foo bar*\"", q("foo bar*").toQueryString()); + } + + @Test + void unfielded_quoted_wildcard_term_stays_unquoted() { + assertEquals("foo*", q("foo*").toQueryString()); + assertEquals("log_?", q("log_?").toQueryString()); + } + + @Test + void unfielded_quoted_escaped_wildcard_emits_phrase() { + assertEquals("\"foo\\*\"", q("foo\\*").toQueryString()); + } + + @Test + void unfielded_unquoted_keeps_bare_term() { + assertEquals("foo", bare("foo").toQueryString()); + assertEquals("foo\\-bar", bare("foo-bar").toQueryString()); + assertEquals("foo*", bare("foo*").toQueryString()); + } + + @Test + void unfielded_non_string_literals_are_unchanged() { + assertEquals("42", new SearchLiteral(new Literal(42, DataType.INTEGER), false).toQueryString()); + } + // ------------------------------------------------------------------------- // End-to-end via SearchComparison + resolver — the actual planner call path. // ------------------------------------------------------------------------- diff --git a/docs/user/ppl/cmd/search.md b/docs/user/ppl/cmd/search.md index 07a4d280723..7001295d49d 100644 --- a/docs/user/ppl/cmd/search.md +++ b/docs/user/ppl/cmd/search.md @@ -40,6 +40,8 @@ The following queries show both syntax types: * **Unquoted**: `search error`, `search user-123`, `search log_*` * **Quoted**: `search "error message"`, `search "user@example.com"` + +A quoted value is matched as a phrase on analyzed text fields, even when it contains no spaces. For example, `search "user-123"` matches only documents in which the analyzed tokens `user` and `123` appear next to each other in that order, while the unquoted `search user-123` matches documents containing either token. A quoted value that contains a wildcard and no spaces, such as `search "log_*"`, is treated as a wildcard pattern instead of a phrase. ### Field values diff --git a/ppl/src/test/java/org/opensearch/sql/ppl/parser/AstSearchExpressionTest.java b/ppl/src/test/java/org/opensearch/sql/ppl/parser/AstSearchExpressionTest.java index e89958c64dd..d75ee3cf449 100644 --- a/ppl/src/test/java/org/opensearch/sql/ppl/parser/AstSearchExpressionTest.java +++ b/ppl/src/test/java/org/opensearch/sql/ppl/parser/AstSearchExpressionTest.java @@ -29,7 +29,7 @@ public void testSimpleSearchTerm() { assertTrue(plan instanceof Search); Search search = (Search) plan; // Double quotes are preserved for phrase search - assertEquals("error", search.getQueryString()); + assertEquals("\"error\"", search.getQueryString()); // Check the child is a Relation assertTrue(search.getChild().get(0) instanceof Relation);