Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@

import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import org.opensearch.sql.data.type.ExprType;

/** Search expression for AND operator. */
@Getter
Expand All @@ -23,8 +25,8 @@ public class SearchAnd extends SearchExpression {
private final SearchExpression right;

@Override
public String toQueryString() {
return left.toQueryString() + " AND " + right.toQueryString();
public String toQueryString(Function<String, ExprType> fieldTypeResolver) {
return left.toQueryString(fieldTypeResolver) + " AND " + right.toQueryString(fieldTypeResolver);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@

import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import org.opensearch.sql.data.type.ExprType;
import org.opensearch.sql.utils.QueryStringUtils;

/** Search expression for field comparisons. */
Expand Down Expand Up @@ -46,9 +48,11 @@ public String getSymbol() {
private final SearchLiteral value;

@Override
public String toQueryString() {
String fieldName = QueryStringUtils.escapeFieldName(field.getField().toString());
String valueStr = value.toQueryString();
public String toQueryString(Function<String, ExprType> fieldTypeResolver) {
String rawFieldName = field.getField().toString();
String fieldName = QueryStringUtils.escapeFieldName(rawFieldName);
ExprType resolvedType = fieldTypeResolver.apply(rawFieldName);
String valueStr = value.toQueryString(resolvedType);
switch (operator) {
case NOT_EQUALS:
return "( _exists_:" + fieldName + " AND NOT " + fieldName + ":" + valueStr + " )";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,33 @@

package org.opensearch.sql.ast.expression;

import java.util.function.Function;
import org.opensearch.sql.ast.AbstractNodeVisitor;
import org.opensearch.sql.data.type.ExprType;

/** Base class for search expressions that get converted to query_string syntax. */
public abstract class SearchExpression extends UnresolvedExpression {

/**
* Convert this search expression to query_string syntax.
* Convert this search expression to query_string syntax without field-type awareness.
*
* @return the query string representation
*/
public abstract String toQueryString();
public String toQueryString() {
return toQueryString(f -> null);
}

/**
* Convert this search expression to query_string syntax, using {@code fieldTypeResolver} to
* resolve the OpenSearch type of a field when the emission depends on whether the field is
* keyword vs. text. When the resolver returns {@code null}, emission falls back to the
* field-type-agnostic form (same as {@link #toQueryString()}).
*
* @param fieldTypeResolver maps a field name to its resolved {@link ExprType}, or null when
* unknown
* @return the query string representation
*/
public abstract String toQueryString(Function<String, ExprType> fieldTypeResolver);

/**
* Convert the search expression to anonymized string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@

import java.util.Collections;
import java.util.List;
import java.util.function.Function;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import org.opensearch.sql.data.type.ExprType;

/** Search expression for grouped expressions (parentheses). */
@Getter
Expand All @@ -22,8 +24,8 @@ public class SearchGroup extends SearchExpression {
private final SearchExpression expression;

@Override
public String toQueryString() {
return "(" + expression.toQueryString() + ")";
public String toQueryString(Function<String, ExprType> fieldTypeResolver) {
return "(" + expression.toQueryString(fieldTypeResolver) + ")";
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@

import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import org.opensearch.sql.data.type.ExprType;
import org.opensearch.sql.utils.QueryStringUtils;

/** Search expression for IN operator. */
Expand All @@ -25,10 +27,12 @@ public class SearchIn extends SearchExpression {
private final List<SearchLiteral> values;

@Override
public String toQueryString() {
public String toQueryString(Function<String, ExprType> fieldTypeResolver) {
String rawFieldName = field.getField().toString();
String fieldName = QueryStringUtils.escapeFieldName(field.getField().toString());
ExprType resolvedType = fieldTypeResolver.apply(rawFieldName);
String valueList =
values.stream().map(SearchLiteral::toQueryString).collect(Collectors.joining(" OR "));
values.stream().map(v -> v.toQueryString(resolvedType)).collect(Collectors.joining(" OR "));

return fieldName + ":( " + valueList + " )";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@

import java.util.Collections;
import java.util.List;
import java.util.function.Function;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
import org.opensearch.sql.data.type.ExprType;
import org.opensearch.sql.utils.QueryStringUtils;

/** Search expression for standalone literals. */
Expand All @@ -21,10 +23,40 @@
public class SearchLiteral extends SearchExpression {

private final UnresolvedExpression literal;
private final boolean isPhrase;

/**
* Whether the user wrote this value inside quotes in the PPL query. On a text field this is the
* user's explicit request for phrase semantics — see {@link #toQueryString(ExprType)}.
*/
private final boolean userQuoted;

@Override
public String toQueryString() {
public String toQueryString(Function<String, ExprType> fieldTypeResolver) {
// Unfielded literal: no enclosing field, so no index type. Take the field-agnostic branch.
return toQueryString((ExprType) null);
}

/**
* Emits the query_string form for a literal on the RHS of {@link SearchComparison} or inside
* {@link SearchIn}. Emission is driven by the enclosing field's index mapping, because the
* mapping decides whether the value gets analyzed:
*
* <ul>
* <li><b>keyword family with a wildcard</b> — the analyzer is a no-op, so the value has to
* reach Lucene as one term for the pattern to apply to the whole stored value. Emitted
* unquoted with whitespace escaped.
* <li><b>text</b> — honor the user's quoting. Unquoted passes through, so {@code *} and {@code
* ?} stay query_string operators. Quoted becomes a phrase, which is how a user asks for
* "this whole value, in order" against the analyzed tokens.
* <li><b>everything else</b> — keyword without a wildcard, plus date, numeric, ip, boolean and
* unresolved fields. Legacy behavior. Note that quoting genuinely carries no information on
* keyword: with a no-op analyzer a quoted phrase and a bare term both resolve to the same
* single term, so there is nothing to gain by rewriting the emission here.
* </ul>
*
* @param indexType the enclosing field's OpenSearch index-mapping type, or null if unresolved
*/
public String toQueryString(ExprType indexType) {
if (literal instanceof Literal) {
Literal lit = (Literal) literal;
Object val = lit.getValue();
Expand All @@ -38,21 +70,69 @@ public String toQueryString() {
if (val instanceof String) {
String str = (String) val;

// Phrase search - preserve quotes
if (isPhrase) {
// Escape special chars inside the phrase
str = QueryStringUtils.escapeLuceneSpecialCharacters(str);
return "\"" + str + "\"";
// A keyword-family field carries whole-value semantics, so a value holding a wildcard has
// to reach Lucene as a single term. Escaping the whitespace keeps query_string from
// splitting at the space and dropping the field binding on the tail.
if (isKeywordLike(indexType) && hasUnescapedWildcard(str)) {
return unquoted(str).replace(" ", "\\ ");
}

if (isTextLike(indexType)) {
// Quoting requests phrase semantics. One exception: a whitespace-free value carrying an
// unescaped wildcard is emitted unquoted, because quoting would let the analyzer discard
// the wildcard (`foo*` would stop matching `foobar`). That is only safe without
// whitespace — with a space, an unquoted value would be split into separate clauses and
// the tail would lose its field binding, so those stay phrases.
boolean wildcardTerm = hasUnescapedWildcard(str) && !str.contains(" ");
return userQuoted && !wildcardTerm ? quoted(str) : unquoted(str);
}

// Regular string - escape special characters
return QueryStringUtils.escapeLuceneSpecialCharacters(str);
// Everything else — keyword without a wildcard, plus date, numeric, ip, boolean and
// unresolved fields: legacy behavior, byte-identical to before this change.
return str.contains(" ") ? quoted(str) : unquoted(str);
}
}

// Default: escape the text representation
String text = literal.toString();
return QueryStringUtils.escapeLuceneSpecialCharacters(text);
return unquoted(literal.toString());
}

private static String quoted(String str) {
return "\"" + QueryStringUtils.escapeLuceneSpecialCharacters(str) + "\"";
}

private static String unquoted(String str) {
return QueryStringUtils.escapeLuceneSpecialCharacters(str);
}

private static boolean isTextLike(ExprType type) {
if (type == null) {
return false;
}
String legacyName = type.getOriginalExprType().legacyTypeName();
return "TEXT".equalsIgnoreCase(legacyName) || "MATCH_ONLY_TEXT".equalsIgnoreCase(legacyName);
}

private static boolean isKeywordLike(ExprType type) {
if (type == null) {
return false;
}
String legacyName = type.getOriginalExprType().legacyTypeName();
return "KEYWORD".equalsIgnoreCase(legacyName)
|| "CONSTANT_KEYWORD".equalsIgnoreCase(legacyName);
}

private static boolean hasUnescapedWildcard(String s) {
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '\\' && i + 1 < s.length()) {
i++;
continue;
}
if (c == '*' || c == '?') {
return true;
}
}
return false;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@

import java.util.Collections;
import java.util.List;
import java.util.function.Function;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import org.opensearch.sql.data.type.ExprType;

/** Search expression for NOT operator. */
@Getter
Expand All @@ -22,8 +24,8 @@ public class SearchNot extends SearchExpression {
private final SearchExpression expression;

@Override
public String toQueryString() {
return "NOT(" + expression.toQueryString() + ")";
public String toQueryString(Function<String, ExprType> fieldTypeResolver) {
return "NOT(" + expression.toQueryString(fieldTypeResolver) + ")";
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@

import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import org.opensearch.sql.data.type.ExprType;

/** Search expression for OR operator. */
@Getter
Expand All @@ -23,8 +25,8 @@ public class SearchOr extends SearchExpression {
private final SearchExpression right;

@Override
public String toQueryString() {
return left.toQueryString() + " OR " + right.toQueryString();
public String toQueryString(Function<String, ExprType> fieldTypeResolver) {
return left.toQueryString(fieldTypeResolver) + " OR " + right.toQueryString(fieldTypeResolver);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@
import org.opensearch.sql.ast.tree.Values;
import org.opensearch.sql.ast.tree.Window;
import org.opensearch.sql.ast.tree.Xyseries;
import org.opensearch.sql.calcite.plan.AbstractOpenSearchTable;
import org.opensearch.sql.calcite.plan.AliasFieldsWrappable;
import org.opensearch.sql.calcite.plan.HighlightPushDown;
import org.opensearch.sql.calcite.plan.OpenSearchConstants;
Expand All @@ -192,6 +193,7 @@
import org.opensearch.sql.common.patterns.PatternUtils;
import org.opensearch.sql.common.utils.StringUtils;
import org.opensearch.sql.data.type.ExprCoreType;
import org.opensearch.sql.data.type.ExprType;
import org.opensearch.sql.datasource.DataSourceService;
import org.opensearch.sql.exception.CalciteUnsupportedException;
import org.opensearch.sql.exception.SemanticCheckException;
Expand Down Expand Up @@ -297,11 +299,33 @@ private RelBuilder scan(RelOptTable tableSchema, CalcitePlanContext context) {
public RelNode visitSearch(Search node, CalcitePlanContext context) {
// Visit the Relation child to get the scan
node.getChild().get(0).accept(this, context);
// Resolve query_string from the structured expression when available so we can consult the
// OpenSearch table's field-type map for per-field text/keyword awareness (e.g. escape
// space + wildcard on keyword vs. quoted phrase on text). Falls back to the pre-computed
// string for callers that never populated the structured expression.
String queryString;
if (node.getOriginalExpression() != null) {
// TODO: index-mapping type (text/keyword) is storage metadata, not a data type — the right
// home is a field/scan annotation on RelDataType, but that needs a Calcite rule-pipeline
// audit (rules rebuild row types and can drop custom fields). For now, unwrap the table
// and read the ExprType map directly.
java.util.Map<String, ExprType> typesByName = new java.util.HashMap<>();
RelNode scan = context.relBuilder.peek();
RelOptTable relOptTable = scan.getTable();
if (relOptTable != null) {
AbstractOpenSearchTable osTable = relOptTable.unwrap(AbstractOpenSearchTable.class);
if (osTable != null) {
typesByName.putAll(osTable.getFieldTypes());
}
}
queryString = node.getOriginalExpression().toQueryString(typesByName::get);
} else {
queryString = node.getQueryString();
}
// Create query_string function
Function queryStringFunc =
AstDSL.function(
"query_string",
AstDSL.unresolvedArg("query", AstDSL.stringLiteral(node.getQueryString())));
"query_string", AstDSL.unresolvedArg("query", AstDSL.stringLiteral(queryString)));
RexNode queryStringRex = rexVisitor.analyze(queryStringFunc, context);

context.relBuilder.filter(queryStringRex);
Expand Down
Loading
Loading