…pensearch-project#5682)
On the Calcite path, `search source=idx name="foo bar*"` against a
keyword field returned 0 hits instead of matching the whole-value pattern
`foo bar*`. The parser marked whitespace-containing literals as phrases,
which emitted `name:"foo bar*"` — inside a Lucene phrase, `*` is a
literal character, so it looked for docs containing `*` in the stored
value and found none.
Route emission per field mapping in
SearchLiteral.toQueryString(ExprType):
- text-like (text, match_only_text) → quoted phrase (unchanged)
- non-text (keyword, etc.) with whitespace + unescaped wildcard →
unquoted term with the space escaped, so query_string keeps the value
as one whole-value pattern instead of splitting into two clauses
- everything else (no whitespace, or phrase without wildcard) →
legacy branches (unquoted-with-escapes, quoted phrase)
The Calcite RelDataType round trip in CalciteRelNodeVisitor.visitSearch
collapses `text` mapping to plain VARCHAR (OpenSearchTypeFactory:208),
which erased the text/keyword distinction at the emitter. Read the
ExprType map directly from AbstractOpenSearchTable.getFieldTypes()
instead; TODO comment marks the follow-up to move this metadata onto a
RelDataType/scan annotation once the Calcite rule pipeline is audited.
Thread a `Function<String, ExprType>` resolver through the SearchExpression
hierarchy (SearchComparison, SearchIn, SearchAnd/Or/Not/Group,
SearchLiteral) so SearchLiteral can consult the resolved field's index
type at emit time.
Tests: 54 new Group1-Group6 tests in CalciteSearchCommandIT covering the
full text × keyword × wildcard-placement matrix on a shared fixture,
plus a core-level SearchLiteralTest for the emission decision table.
Verified with `./gradlew doctest -DignorePrometheus` (85 tests) and
`./gradlew -DignorePrometheus :integ-test:integTest` (30m36s, 0 failures).
Signed-off-by: Peng Huo <penghuo@gmail.com>
Description
On the Calcite path,
search source=idx name="foo bar*"against a keyword field returned 0 hits instead of matching the whole-value patternfoo bar*. The parser marked whitespace-containing literals as phrases, which emittedname:"foo bar*"— inside a Lucene phrase,*is a literal character, so the query searched for docs containing*in the stored value and found none.Emission strategy
PPL emits a single
query_stringfilter for the entire search predicate (never a specific Lucene query type — thequery_stringparser inside OpenSearch decides that at execution time based on the parsed operators and the target field's analyzer). The emitter's job is to produce the right string.The decision is driven by three orthogonal properties of the PPL literal and its enclosing field:
SearchLiteral.isPhrase.*/?) — scanned byhasUnescapedWildcard.textormatch_only_text. Read from the OpenSearch table's field-type map directly (AbstractOpenSearchTable.getFieldTypes()), because the CalciteRelDataTyperound trip collapsestextto plainVARCHARand loses the distinction.Decision notes
[A] Whitespace-free value → unquoted, escape specials, keep wildcards. Field-type-agnostic. The
query_stringparser sees a single term; the field's own analyzer (or lack of one, for keyword) decides matching at execution time.name=foo→name:fooname="foo-bar"→name:foo\-barname="foo*"→name:foo*(parser sees an unescaped*, builds a prefix query)name="foo/*"→name:foo\/*(/escaped so it isn't parsed as a regex delimiter,*preserved)[B] Whitespace value without wildcards → quoted phrase. Preserves phrase semantics without needing to think about the field type: on keyword it matches the whole term; on text it becomes a
PhraseQueryover analyzed tokens.name="foo bar"→name:"foo bar"name="hello world"→name:"hello world"[C] Whitespace + wildcard on a text-like field → quoted phrase. Inside a phrase, the text analyzer strips
*/?as punctuation and matches the remaining tokens. That's the natural bag-of-words behavior users expect on text fields, and it matches pre-fix behavior (no regression).name="foo bar*"ontext→name:"foo bar*"(analyzer emits tokens[foo, bar], matches phrases containing them)name="*foo bar*"ontext→name:"*foo bar*"(same, wildcards stripped inside the phrase)[D] Whitespace + wildcard on a non-text field (keyword,
constant_keyword,wildcard, numeric, etc.) → unquoted, escape everything including the space. This is the reported bug (#5682) fix. Emitting a quoted phrase here on keyword makes the literal*/?search for those characters in the stored value (they aren't operators inside a phrase), which returns zero. Emitting unquoted with the space un-escaped is worse: thequery_stringparser splits at the space into two clauses (name:foo+ unfieldedbar*), dropping the field binding on the right half. Escaping the space keeps the value a single term with active wildcards; the parser builds a whole-value pattern match against the keyword's stored term.name="foo bar*"onkeyword→name:foo\ bar*(matchesfoo bar,foo barbaz)name="*foo bar*"onkeyword→name:*foo\ bar*(matchesfoo bar,foo barbaz)name="foo b?r"onkeyword→name:foo\ b?r(matchesfoo bar)About "text-like". The predicate returns true only for
textandmatch_only_text. Every other mapping —keyword,constant_keyword,wildcard, numeric types, date, boolean — routes to [D]. When the field type can't be resolved (scan doesn't unwrap toAbstractOpenSearchTable, field missing from the type map), the predicate returns true as a regression-safe fallback so we take the [C] phrase branch. This case is dead code for well-formed queries against real indices.Measured contract
Fixture. Two indices with identical documents.
test_5682_keywordmapsnametokeyword;test_5682_textmapsnametotext(standard analyzer). 11 documents each, values:foo,foobar,food,FOO,foo bar,foo barbaz,foo-bar,foo_bar,foo.bar,foo/bar,foo@bar.Group 1 — no special chars, no wildcards
name=fooname:fooname:fooname="foo"name:fooname:fooGroup 2 — special chars in value, no wildcards
name="foo_bar"name:foo_barname:foo_barname="foo.bar"name:foo.barname:foo.barname="foo-bar"name:foo\-barname:foo\-barname="foo/bar"name:foo\/barname:foo\/barname="foo@bar"name:foo@barname:foo@barname="foo bar"name:"foo bar"name:"foo bar"Group 3 — trailing wildcard (postfix)
name=foo*name:foo*name:foo*name="foo*"name:foo*name:foo*name="foo_*"name:foo_*name:foo_*name="foo.*"name:foo.*name:foo.*name="foo-*"name:foo\-*name:foo\-*name="foo/*"name:foo\/*name:foo\/*name="foo bar*"name:"foo bar*"name:foo\ bar*Group 4 — leading wildcard (prefix)
name="*foo"name:*fooname:*fooname="*bar"name:*barname:*barname="*foo bar"name:"*foo bar"name:*foo\ barGroup 5 — interior wildcard (
*in-between)name="f*r"name:f*rname:f*rname="foo*bar"name:foo*barname:foo*barname="foo *baz"name:"foo *baz"name:foo\ *bazname="*foo bar*"name:"*foo bar*"name:*foo\ bar*Note on 5.3 text: the analyzer strips
*inside the phrase and produces tokens[foo, baz]. On this corpus no doc has adjacent analyzed tokensfoo → baz(the closest isfoo barbaz, which tokenizes to[foo, barbaz]—barbazis one token, not[bar, baz]). Zero on text is a corpus/analyzer interaction, not an emission failure. Same phrase shape with a present token — e.g.name:"foo *bar"— returns 4 hits on text against this corpus (baris an indexed token).Group 6 —
?wildcard (exactly one character)name="foo?"name:foo?name:foo?name="?oo"name:?ooname:?ooname="f?o"name:f?oname:f?oname="foo?bar"name:foo?barname:foo?barname="foo b?r"name:"foo b?r"name:foo\ b?rNote on 6.5 text: analyzer produces
[foo, br](drops?as punctuation, splits at whitespace). No corpus doc has adjacent tokensfoo → br. Mathematically correct given the analyzer's behavior.Tests
CalciteSearchCommandITcovering the full text × keyword × wildcard-placement matrix on the fixture above.Related Issues
Resolves #5682
Check List
--signoffor-s.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.