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
71 changes: 71 additions & 0 deletions sandbox/plugins/dsl-query-executor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,77 @@ _search request
→ SearchResponseBuilder (builds SearchResponse)
```

## Supported Queries

### Term Query
Converts to Calcite equality expressions.
```json
{"term": {"status": "active"}}
```

### Match All Query
Converts to boolean true literal.
```json
{"match_all": {}}
```

### Prefix Query
Converts to Calcite LIKE expressions with wildcard suffix.

**Supported parameters:**
- `value` - The prefix string
- `case_insensitive` - Case-insensitive matching (default: false)

**Unsupported parameters (throw ConversionException):**
- `boost` - Query boosting not supported
- `rewrite` - Rewrite method not supported

**Examples:**
```json
{"prefix": {"name": "lap"}}
// Converts to: name LIKE 'lap%'

{"prefix": {"name": {"value": "LAP", "case_insensitive": true}}}
// Converts to: LOWER(name) LIKE 'lap%'
```

**Special character escaping:**
- `%` → `\%` (SQL wildcard for any characters)
- `_` → `\_` (SQL wildcard for single character)
- `\` → `\\` (escape character)

### Wildcard Query
Converts to Calcite LIKE expressions with wildcard pattern translation.

**Wildcard characters:**
- `*` - Matches any character sequence (converts to SQL `%`)
- `?` - Matches any single character (converts to SQL `_`)

**Supported parameters:**
- `value` - The wildcard pattern
- `case_insensitive` - Case-insensitive matching (default: false)

**Unsupported parameters (throw ConversionException):**
- `boost` - Query boosting not supported
- `rewrite` - Rewrite method not supported

**Examples:**
```json
{"wildcard": {"name": "lap*"}}
// Converts to: name LIKE 'lap%'

{"wildcard": {"name": "l?ptop"}}
// Converts to: name LIKE 'l_ptop'

{"wildcard": {"name": {"value": "*BOOK*", "case_insensitive": true}}}
// Converts to: LOWER(name) LIKE '%book%'
```

**Special character escaping:**
- SQL special chars (`%`, `_`, `\`) are escaped before wildcard conversion
- `*` → `%` (after escaping)
- `?` → `_` (after escaping)

## Dependencies

- `analytics-engine` — provides `QueryPlanExecutor` and `EngineContext` via Guice (declared as `extendedPlugins`)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/

package org.opensearch.dsl;

import org.opensearch.common.xcontent.XContentType;
import org.opensearch.index.query.QueryBuilders;
import org.opensearch.search.builder.SearchSourceBuilder;

/**
* Integration tests for prefix query conversion to Calcite LIKE expressions.
*/
public class DslPrefixQueryIT extends DslIntegTestBase {

@Override
protected void createTestIndex() {
createIndex(INDEX);
ensureGreen();

client().prepareIndex(INDEX)
.setId("1")
.setSource("{\"name\":\"laptop\",\"brand\":\"Apple\",\"model\":\"MacBook Pro\"}", XContentType.JSON)
.get();
client().prepareIndex(INDEX)
.setId("2")
.setSource("{\"name\":\"phone\",\"brand\":\"Samsung\",\"model\":\"Galaxy S21\"}", XContentType.JSON)
.get();
client().prepareIndex(INDEX)
.setId("3")
.setSource("{\"name\":\"tablet\",\"brand\":\"apple\",\"model\":\"iPad Air\"}", XContentType.JSON)
.get();
refresh(INDEX);
}

public void testBasicPrefixQuery() {
createTestIndex();
assertOk(search(new SearchSourceBuilder().query(
QueryBuilders.prefixQuery("name", "lap")
)));
}

public void testPrefixQueryCaseSensitive() {
createTestIndex();
assertOk(search(new SearchSourceBuilder().query(
QueryBuilders.prefixQuery("brand", "Apple")
)));
}

public void testPrefixQueryCaseInsensitive() {
createTestIndex();
assertOk(search(new SearchSourceBuilder().query(
QueryBuilders.prefixQuery("brand", "apple").caseInsensitive(true)
)));
}

public void testPrefixQueryWithEmptyString() {
createTestIndex();
assertOk(search(new SearchSourceBuilder().query(
QueryBuilders.prefixQuery("name", "")
)));
}

public void testPrefixQueryWithMultipleWords() {
createTestIndex();
assertOk(search(new SearchSourceBuilder().query(
QueryBuilders.prefixQuery("model", "MacBook")
)));
}

public void testPrefixQueryInBoolQuery() {
createTestIndex();
assertOk(search(new SearchSourceBuilder().query(
QueryBuilders.boolQuery()
.must(QueryBuilders.prefixQuery("name", "lap"))
.should(QueryBuilders.prefixQuery("brand", "App"))
)));
}

public void testPrefixQueryWithSpecialCharacters() {
createTestIndex();
assertOk(search(new SearchSourceBuilder().query(
QueryBuilders.prefixQuery("model", "Galaxy S")
)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/

package org.opensearch.dsl;

import org.opensearch.common.xcontent.XContentType;
import org.opensearch.index.query.QueryBuilders;
import org.opensearch.search.builder.SearchSourceBuilder;

/**
* Integration tests for wildcard query conversion to Calcite LIKE expressions.
*/
public class DslWildcardQueryIT extends DslIntegTestBase {

@Override
protected void createTestIndex() {
createIndex(INDEX);
ensureGreen();

client().prepareIndex(INDEX)
.setId("1")
.setSource("{\"name\":\"laptop\",\"model\":\"MacBook Pro\",\"sku\":\"MB-2021-001\"}", XContentType.JSON)
.get();
client().prepareIndex(INDEX)
.setId("2")
.setSource("{\"name\":\"phone\",\"model\":\"Galaxy S21\",\"sku\":\"GS-2021-002\"}", XContentType.JSON)
.get();
client().prepareIndex(INDEX)
.setId("3")
.setSource("{\"name\":\"tablet\",\"model\":\"iPad Air\",\"sku\":\"IP-2020-003\"}", XContentType.JSON)
.get();
refresh(INDEX);
}

public void testWildcardWithAsterisk() {
createTestIndex();
assertOk(search(new SearchSourceBuilder().query(
QueryBuilders.wildcardQuery("name", "lap*")
)));
}

public void testWildcardWithQuestionMark() {
createTestIndex();
assertOk(search(new SearchSourceBuilder().query(
QueryBuilders.wildcardQuery("name", "p?one")
)));
}

public void testWildcardWithBothWildcards() {
createTestIndex();
assertOk(search(new SearchSourceBuilder().query(
QueryBuilders.wildcardQuery("model", "?acBook*")
)));
}

public void testWildcardCaseInsensitive() {
createTestIndex();
assertOk(search(new SearchSourceBuilder().query(
QueryBuilders.wildcardQuery("model", "MACBOOK*").caseInsensitive(true)
)));
}

public void testWildcardWithMultipleAsterisks() {
createTestIndex();
assertOk(search(new SearchSourceBuilder().query(
QueryBuilders.wildcardQuery("sku", "*-2021-*")
)));
}

public void testWildcardMatchAll() {
createTestIndex();
assertOk(search(new SearchSourceBuilder().query(
QueryBuilders.wildcardQuery("name", "*")
)));
}

public void testWildcardInBoolQuery() {
createTestIndex();
assertOk(search(new SearchSourceBuilder().query(
QueryBuilders.boolQuery()
.must(QueryBuilders.wildcardQuery("name", "lap*"))
.should(QueryBuilders.wildcardQuery("model", "*Pro"))
)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/

package org.opensearch.dsl.query;

import org.apache.calcite.rel.type.RelDataTypeField;
import org.apache.calcite.rex.RexNode;
import org.apache.calcite.sql.fun.SqlStdOperatorTable;
import org.opensearch.dsl.converter.ConversionContext;
import org.opensearch.dsl.converter.ConversionException;
import org.opensearch.index.query.PrefixQueryBuilder;
import org.opensearch.index.query.QueryBuilder;

/**
* Converts a {@link PrefixQueryBuilder} to a Calcite LIKE expression.
* <p>
* Converts prefix queries to SQL LIKE patterns with a trailing wildcard:
* <ul>
* <li>{@code {"prefix": {"name": "lap"}}} → {@code name LIKE 'lap%'}</li>
* <li>{@code {"prefix": {"name": {"value": "lap", "case_insensitive": true}}}} → {@code LOWER(name) LIKE 'lap%'}</li>
* </ul>
* <p>
* <b>Supported parameters:</b>
* <ul>
* <li>{@code value} - The prefix string to match</li>
* <li>{@code case_insensitive} - When true, applies LOWER() to both field and pattern (default: false)</li>
* </ul>
* <p>
* <b>Unsupported parameters</b> (throw {@link ConversionException}):
* <ul>
* <li>{@code boost} - Query boosting not supported in analytics engine</li>
* <li>{@code rewrite} - Lucene-specific rewrite methods not applicable to Calcite</li>
* </ul>
* <p>
* <b>Special character escaping:</b>
* SQL LIKE special characters in the prefix value are automatically escaped:
* <ul>
* <li>{@code %} → {@code \%} (SQL any-characters wildcard)</li>
* <li>{@code _} → {@code \_} (SQL single-character wildcard)</li>
* <li>{@code \} → {@code \\} (escape character)</li>
* </ul>
* <p>
* Example: {@code {"prefix": {"path": "C:\\test_"}}} → {@code path LIKE 'C:\\\\test\\_%'}
*/
public class PrefixQueryTranslator implements QueryTranslator {

/**
* Returns the query type this translator handles.
*
* @return {@link PrefixQueryBuilder} class
*/
@Override
public Class<? extends QueryBuilder> getQueryType() {
return PrefixQueryBuilder.class;
}

/**
* Converts a prefix query to a Calcite LIKE expression.
* <p>
* Validates field existence, checks for unsupported parameters, applies case-insensitive
* transformation if needed, escapes SQL special characters, and appends trailing wildcard.
*
* @param query the prefix query to convert
* @param ctx the conversion context with schema and RexBuilder
* @return RexNode representing {@code field LIKE 'prefix%'} or {@code LOWER(field) LIKE 'prefix%'}
* @throws ConversionException if field not found, or boost/rewrite parameters are set
*/
@Override
public RexNode convert(QueryBuilder query, ConversionContext ctx) throws ConversionException {
PrefixQueryBuilder prefixQuery = (PrefixQueryBuilder) query;

// Check for unsupported parameters
if (prefixQuery.boost() != 1.0f) {
throw new ConversionException("Prefix query parameter 'boost' is not supported");
}
if (prefixQuery.rewrite() != null) {
throw new ConversionException("Prefix query parameter 'rewrite' is not supported");
}

String fieldName = prefixQuery.fieldName();
String prefix = prefixQuery.value();
boolean caseInsensitive = prefixQuery.caseInsensitive();

// Validate field exists in schema
RelDataTypeField field = ctx.getRowType().getField(fieldName, false, false);
if (field == null) {
throw new ConversionException("Field '" + fieldName + "' not found in schema");
}

// Create field reference
RexNode fieldRef = ctx.getRexBuilder().makeInputRef(field.getType(), field.getIndex());

// Apply LOWER() if case insensitive
if (caseInsensitive) {
fieldRef = ctx.getRexBuilder().makeCall(SqlStdOperatorTable.LOWER, fieldRef);
prefix = prefix.toLowerCase();
}

// Create LIKE pattern: prefix + '%'
String likePattern = escapeLikePattern(prefix) + "%";
RexNode patternLiteral = ctx.getRexBuilder().makeLiteral(likePattern);

// Return LIKE expression
return ctx.getRexBuilder().makeCall(SqlStdOperatorTable.LIKE, fieldRef, patternLiteral);
}

/**
* Escapes SQL LIKE special characters in the prefix value.
* <p>
* Escapes characters that have special meaning in SQL LIKE patterns:
* <ul>
* <li>{@code \} → {@code \\} (must be escaped first to avoid double-escaping)</li>
* <li>{@code %} → {@code \%} (matches any sequence of characters)</li>
* <li>{@code _} → {@code \_} (matches any single character)</li>
* </ul>
* <p>
* Example: {@code "test_50%"} → {@code "test\_50\%"}
*
* @param value the prefix value to escape
* @return escaped value safe for use in LIKE pattern
*/
private String escapeLikePattern(String value) {
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public static QueryRegistry create() {
QueryRegistry registry = new QueryRegistry();
registry.register(new TermQueryTranslator());
registry.register(new MatchAllQueryTranslator());
registry.register(new PrefixQueryTranslator());
registry.register(new WildcardQueryTranslator());
// TODO: add other query translators
return registry;
}
Expand Down
Loading