Skip to content
Merged
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 @@ -47,6 +47,11 @@ public enum ScalarFunction {
// ── Full-text search ─────────────────────────────────────────────
MATCH(Category.FULL_TEXT, SqlKind.OTHER_FUNCTION),
MATCH_PHRASE(Category.FULL_TEXT, SqlKind.OTHER_FUNCTION),
MATCH_BOOL_PREFIX(Category.FULL_TEXT, SqlKind.OTHER_FUNCTION),
MATCH_PHRASE_PREFIX(Category.FULL_TEXT, SqlKind.OTHER_FUNCTION),
MULTI_MATCH(Category.FULL_TEXT, SqlKind.OTHER_FUNCTION),
QUERY_STRING(Category.FULL_TEXT, SqlKind.OTHER_FUNCTION),
SIMPLE_QUERY_STRING(Category.FULL_TEXT, SqlKind.OTHER_FUNCTION),
FUZZY(Category.FULL_TEXT, SqlKind.OTHER_FUNCTION),
WILDCARD(Category.FULL_TEXT, SqlKind.OTHER_FUNCTION),
REGEXP(Category.FULL_TEXT, SqlKind.OTHER_FUNCTION),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import org.opensearch.index.query.QueryBuilder;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

/**
Expand All @@ -30,6 +31,13 @@
*/
final class ConversionUtils {

/** MAP key for single-field relevance operands. */
static final String KEY_FIELD = "field";
/** MAP key for multi-field relevance operands. */
static final String KEY_FIELDS = "fields";
/** MAP key for the query text operand. */
static final String KEY_QUERY = "query";

private ConversionUtils() {}

/**
Expand Down Expand Up @@ -77,4 +85,121 @@ static byte[] serializeQueryBuilder(QueryBuilder queryBuilder) {
throw new IllegalStateException("Failed to serialize delegated query: " + queryBuilder, exception);
}
}

/**
* Extracts the key string from a MAP_VALUE_CONSTRUCTOR operand: MAP('key', value).
* Returns null if the operand is not a MAP or the key is not a string literal.
*/
static String extractMapKey(RexCall call, int operandIndex) {
RexNode operand = call.getOperands().get(operandIndex);
if (operand instanceof RexCall mapCall && mapCall.getOperands().size() >= 2) {
RexNode key = mapCall.getOperands().get(0);
if (key instanceof RexLiteral literal) {
return literal.getValueAs(String.class);
}
}
return null;
}

/**
* Extracted operands from a relevance function RexCall.
* @param fieldName single field name (null if not present or multi-field)
* @param fields multiple field names (null if not present)
* @param query the query string (null if not found)
*/
record RelevanceOperands(String fieldName, List<String> fields, String query) {
}

/**
* Extracts field/fields and query from a relevance function RexCall by MAP key lookup,
* with positional fallback for non-MAP operand structures (e.g. MATCH($ref, literal)).
*
* @param call the relevance function RexCall
* @param fieldStorage per-column storage metadata for resolving field names
* @return extracted operands
*/
static RelevanceOperands extractRelevanceOperands(RexCall call, List<FieldStorageInfo> fieldStorage) {
String fieldName = null;
List<String> fields = null;
String query = null;

for (int i = 0; i < call.getOperands().size(); i++) {
String key = extractMapKey(call, i);
if (KEY_FIELD.equals(key)) {
fieldName = extractFieldFromRelevanceMap(call, i, fieldStorage);
} else if (KEY_FIELDS.equals(key)) {
fields = extractFieldsFromRelevanceMap(call, i, fieldStorage);
} else if (KEY_QUERY.equals(key)) {
query = extractStringFromRelevanceMap(call, i);
}
}

// Fallback: positional extraction for non-MAP operand structures (e.g. MATCH($ref, literal))
if (fieldName == null && fields == null && query == null && call.getOperands().size() >= 2) {
fieldName = extractFieldFromRelevanceMap(call, 0, fieldStorage);
query = extractStringFromRelevanceMap(call, 1);
}

return new RelevanceOperands(fieldName, fields, query);
}

/**
* Extracts multiple field names from a MAP_VALUE_CONSTRUCTOR operand
* for multi-field full-text functions (multi_match, query_string, simple_query_string).
*
* <p>The operand structure for multi-field functions:
* {@code MAP('fields', MAP('field1':VARCHAR, boost1:DOUBLE, 'field2':VARCHAR, boost2:DOUBLE, ...))}
* The outer MAP has key='fields' at index 0 and a nested MAP at index 1.
* The nested MAP is a Calcite MAP_VALUE_CONSTRUCTOR with strict alternating key-value pairs:
* field name (VARCHAR) at even indices, boost value (DOUBLE) at odd indices.
*
* <p>Also supports the RexInputRef-based structure for single-field fallback:
* {@code MAP('field', $ref1, 'field', $ref2, ...)}
*
* <p>Note: This method is intentionally not recursive. The MAP nesting depth is bounded
* to at most 2 levels by Calcite's MAP_VALUE_CONSTRUCTOR design: an outer MAP holding
* the 'fields' key and a nested MAP holding field-name/boost pairs. Deeper nesting does
* not occur in the PPL relevance function encoding.
*
* <p>TODO: extract per-field boost values and return them alongside field names.
*/
static List<String> extractFieldsFromRelevanceMap(RexCall call, int operandIndex, List<FieldStorageInfo> fieldStorage) {
Comment thread
nssuresh2007 marked this conversation as resolved.
RexNode operand = call.getOperands().get(operandIndex);
List<String> fields = new ArrayList<>();
if (operand instanceof RexCall outerMapCall) {
// Check if the value (index 1) is a nested MAP containing field name/boost pairs
if (outerMapCall.getOperands().size() >= 2) {
RexNode value = outerMapCall.getOperands().get(1);
if (value instanceof RexCall nestedMapCall) {
// Nested MAP: strict alternating key-value pairs from MAP_VALUE_CONSTRUCTOR.
// Even indices (0, 2, 4...) are field name VARCHAR literals.
// Odd indices (1, 3, 5...) are boost DOUBLE literals (ignored for now).
List<RexNode> nestedOperands = nestedMapCall.getOperands();
for (int i = 0; i < nestedOperands.size(); i += 2) {
RexNode fieldNode = nestedOperands.get(i);
if (fieldNode instanceof RexLiteral fieldLiteral) {
fields.add(fieldLiteral.getValueAs(String.class));
}
}
if (fields.isEmpty() == false) {
return fields;
}
}
}
// Fallback: RexInputRef-based structure MAP('field', $ref1, 'field', $ref2, ...)
List<RexNode> mapOperands = outerMapCall.getOperands();
for (int i = 1; i < mapOperands.size(); i += 2) {
RexNode val = mapOperands.get(i);
if (val instanceof RexInputRef inputRef) {
fields.add(FieldStorageInfo.resolve(fieldStorage, inputRef.getIndex()).getFieldName());
}
}
} else if (operand instanceof RexInputRef inputRef) {
fields.add(FieldStorageInfo.resolve(fieldStorage, inputRef.getIndex()).getFieldName());
}
if (fields.isEmpty()) {
throw new IllegalArgumentException("Cannot extract field names from operand " + operandIndex + ": " + operand);
}
return fields;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ public class LuceneAnalyticsBackendPlugin implements AnalyticsSearchBackendPlugi
private static final Set<ScalarFunction> FULL_TEXT_OPS = Set.of(
ScalarFunction.MATCH,
ScalarFunction.MATCH_PHRASE,
ScalarFunction.MATCH_BOOL_PREFIX,
ScalarFunction.MATCH_PHRASE_PREFIX,
ScalarFunction.MULTI_MATCH,
ScalarFunction.QUERY_STRING,
ScalarFunction.SIMPLE_QUERY_STRING,
ScalarFunction.FUZZY,
ScalarFunction.WILDCARD,
ScalarFunction.REGEXP
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@
import org.opensearch.analytics.spi.DelegatedPredicateSerializer;
import org.opensearch.analytics.spi.FieldStorageInfo;
import org.opensearch.analytics.spi.ScalarFunction;
import org.opensearch.index.query.MatchBoolPrefixQueryBuilder;
import org.opensearch.index.query.MatchPhrasePrefixQueryBuilder;
import org.opensearch.index.query.MatchPhraseQueryBuilder;
import org.opensearch.index.query.MatchQueryBuilder;
import org.opensearch.index.query.MultiMatchQueryBuilder;
import org.opensearch.index.query.QueryStringQueryBuilder;
import org.opensearch.index.query.SimpleQueryStringBuilder;

import java.util.List;
import java.util.Map;
Expand All @@ -21,15 +27,17 @@
* Registry of per-function query serializers for delegated predicates.
* Each serializer converts a Calcite RexCall into serialized QueryBuilder bytes
* that the Lucene backend can deserialize at the data node.
*
* <p>TODO: add serializers for match_phrase, match_bool_prefix, match_phrase_prefix.
* TODO: add multi-field relevance serializers for multi_match, query_string, simple_query_string.
*/
final class QuerySerializerRegistry {

private static final Map<ScalarFunction, DelegatedPredicateSerializer> SERIALIZERS = Map.of(
ScalarFunction.MATCH,
QuerySerializerRegistry::serializeMatch
private static final Map<ScalarFunction, DelegatedPredicateSerializer> SERIALIZERS = Map.ofEntries(
Map.entry(ScalarFunction.MATCH, QuerySerializerRegistry::serializeMatch),
Map.entry(ScalarFunction.MATCH_PHRASE, QuerySerializerRegistry::serializeMatchPhrase),
Map.entry(ScalarFunction.MATCH_BOOL_PREFIX, QuerySerializerRegistry::serializeMatchBoolPrefix),
Map.entry(ScalarFunction.MATCH_PHRASE_PREFIX, QuerySerializerRegistry::serializeMatchPhrasePrefix),
Map.entry(ScalarFunction.MULTI_MATCH, QuerySerializerRegistry::serializeMultiMatch),
Map.entry(ScalarFunction.QUERY_STRING, QuerySerializerRegistry::serializeQueryString),
Map.entry(ScalarFunction.SIMPLE_QUERY_STRING, QuerySerializerRegistry::serializeSimpleQueryString)
);

private QuerySerializerRegistry() {}
Expand All @@ -38,11 +46,89 @@ static Map<ScalarFunction, DelegatedPredicateSerializer> getSerializers() {
return SERIALIZERS;
}

// TODO: Extract each serialize* method into its own dedicated class once we handle more parameters.
// These methods are expected to grow significantly as optional parameters are added.

private static byte[] serializeMatch(RexCall call, List<FieldStorageInfo> fieldStorage) {
String fieldName = ConversionUtils.extractFieldFromRelevanceMap(call, 0, fieldStorage);
String queryText = ConversionUtils.extractStringFromRelevanceMap(call, 1);
// TODO: extract optional params (operator, analyzer, fuzziness) from operands 2+
MatchQueryBuilder queryBuilder = new MatchQueryBuilder(fieldName, queryText);
ConversionUtils.RelevanceOperands operands = ConversionUtils.extractRelevanceOperands(call, fieldStorage);
if (operands.fieldName() == null || operands.query() == null) {
throw new IllegalArgumentException("match requires 'field' and 'query' parameters, got: " + call);
}
// TODO: extract optional params (operator, analyzer, fuzziness, boost)
MatchQueryBuilder queryBuilder = new MatchQueryBuilder(operands.fieldName(), operands.query());
return ConversionUtils.serializeQueryBuilder(queryBuilder);
}

private static byte[] serializeMatchPhrase(RexCall call, List<FieldStorageInfo> fieldStorage) {
Comment thread
nssuresh2007 marked this conversation as resolved.
ConversionUtils.RelevanceOperands operands = ConversionUtils.extractRelevanceOperands(call, fieldStorage);
if (operands.fieldName() == null || operands.query() == null) {
throw new IllegalArgumentException("match_phrase requires 'field' and 'query' parameters, got: " + call);
}
// TODO: extract optional params (slop, analyzer, zero_terms_query)
MatchPhraseQueryBuilder queryBuilder = new MatchPhraseQueryBuilder(operands.fieldName(), operands.query());
return ConversionUtils.serializeQueryBuilder(queryBuilder);
}

private static byte[] serializeMatchBoolPrefix(RexCall call, List<FieldStorageInfo> fieldStorage) {
ConversionUtils.RelevanceOperands operands = ConversionUtils.extractRelevanceOperands(call, fieldStorage);
if (operands.fieldName() == null || operands.query() == null) {
throw new IllegalArgumentException("match_bool_prefix requires 'field' and 'query' parameters, got: " + call);
}
// TODO: extract optional params (analyzer, fuzziness, operator, minimum_should_match)
MatchBoolPrefixQueryBuilder queryBuilder = new MatchBoolPrefixQueryBuilder(operands.fieldName(), operands.query());
return ConversionUtils.serializeQueryBuilder(queryBuilder);
}

private static byte[] serializeMatchPhrasePrefix(RexCall call, List<FieldStorageInfo> fieldStorage) {
ConversionUtils.RelevanceOperands operands = ConversionUtils.extractRelevanceOperands(call, fieldStorage);
if (operands.fieldName() == null || operands.query() == null) {
throw new IllegalArgumentException("match_phrase_prefix requires 'field' and 'query' parameters, got: " + call);
}
// TODO: extract optional params (slop, analyzer, max_expansions, zero_terms_query)
MatchPhrasePrefixQueryBuilder queryBuilder = new MatchPhrasePrefixQueryBuilder(operands.fieldName(), operands.query());
return ConversionUtils.serializeQueryBuilder(queryBuilder);
}

private static byte[] serializeMultiMatch(RexCall call, List<FieldStorageInfo> fieldStorage) {
ConversionUtils.RelevanceOperands operands = ConversionUtils.extractRelevanceOperands(call, fieldStorage);
if (operands.query() == null) {
throw new IllegalArgumentException("multi_match requires a 'query' parameter, got: " + call);
}
// TODO: extract per-field boost values and optional params (type, operator, analyzer, fuzziness)
List<String> fields = operands.fields();
MultiMatchQueryBuilder queryBuilder = fields != null
? new MultiMatchQueryBuilder(operands.query(), fields.toArray(String[]::new))
: new MultiMatchQueryBuilder(operands.query());
return ConversionUtils.serializeQueryBuilder(queryBuilder);
}

private static byte[] serializeQueryString(RexCall call, List<FieldStorageInfo> fieldStorage) {
Comment thread
nssuresh2007 marked this conversation as resolved.
ConversionUtils.RelevanceOperands operands = ConversionUtils.extractRelevanceOperands(call, fieldStorage);
if (operands.query() == null) {
throw new IllegalArgumentException("query_string requires a 'query' parameter, got: " + call);
}
// TODO: extract optional params (default_operator, analyzer, allow_leading_wildcard)
QueryStringQueryBuilder queryBuilder = new QueryStringQueryBuilder(operands.query());
if (operands.fields() != null) {
for (String field : operands.fields()) {
queryBuilder.field(field);
}
}
return ConversionUtils.serializeQueryBuilder(queryBuilder);
}

private static byte[] serializeSimpleQueryString(RexCall call, List<FieldStorageInfo> fieldStorage) {
ConversionUtils.RelevanceOperands operands = ConversionUtils.extractRelevanceOperands(call, fieldStorage);
if (operands.query() == null) {
throw new IllegalArgumentException("simple_query_string requires a 'query' parameter, got: " + call);
}
// TODO: extract optional params (default_operator, analyzer, flags, minimum_should_match)
SimpleQueryStringBuilder queryBuilder = new SimpleQueryStringBuilder(operands.query());
if (operands.fields() != null) {
for (String field : operands.fields()) {
queryBuilder.field(field);
}
}
return ConversionUtils.serializeQueryBuilder(queryBuilder);
}
}
Loading
Loading