diff --git a/sandbox/plugins/dsl-query-executor/README.md b/sandbox/plugins/dsl-query-executor/README.md index 81228148044ee..424997dfa772f 100644 --- a/sandbox/plugins/dsl-query-executor/README.md +++ b/sandbox/plugins/dsl-query-executor/README.md @@ -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`) diff --git a/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslPrefixQueryIT.java b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslPrefixQueryIT.java new file mode 100644 index 0000000000000..77197df570b5e --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslPrefixQueryIT.java @@ -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") + ))); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslWildcardQueryIT.java b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslWildcardQueryIT.java new file mode 100644 index 0000000000000..c42d1660e8651 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslWildcardQueryIT.java @@ -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")) + ))); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/PrefixQueryTranslator.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/PrefixQueryTranslator.java new file mode 100644 index 0000000000000..4eb84603150a0 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/PrefixQueryTranslator.java @@ -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. + *
+ * Converts prefix queries to SQL LIKE patterns with a trailing wildcard: + *
+ * Supported parameters: + *
+ * Unsupported parameters (throw {@link ConversionException}): + *
+ * Special character escaping: + * SQL LIKE special characters in the prefix value are automatically escaped: + *
+ * 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. + *
+ * 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. + *
+ * Escapes characters that have special meaning in SQL LIKE patterns: + *
+ * 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("_", "\\_"); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryRegistryFactory.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryRegistryFactory.java index 5313c1d40253b..d1a7c63cf8e29 100644 --- a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryRegistryFactory.java +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryRegistryFactory.java @@ -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; } diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/WildcardQueryTranslator.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/WildcardQueryTranslator.java new file mode 100644 index 0000000000000..952e436edc718 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/WildcardQueryTranslator.java @@ -0,0 +1,170 @@ +/* + * 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.QueryBuilder; +import org.opensearch.index.query.WildcardQueryBuilder; + +/** + * Converts a {@link WildcardQueryBuilder} to a Calcite LIKE expression. + *
+ * Translates OpenSearch wildcard patterns to SQL LIKE patterns: + *
+ * Examples: + *
+ * Supported parameters: + *
+ * Unsupported parameters (throw {@link ConversionException}): + *
+ * Special character handling: + * SQL LIKE special characters ({@code %}, {@code _}, {@code \}) in the pattern are escaped + * before wildcard conversion to prevent unintended matching. + *
+ * Example: {@code {"wildcard": {"name": "a%b_c\\d*"}}} → {@code name LIKE 'a\%b\_c\\\\d%'} + */ +public class WildcardQueryTranslator implements QueryTranslator { + + /** + * Returns the query type this translator handles. + * + * @return {@link WildcardQueryBuilder} class + */ + @Override + public Class extends QueryBuilder> getQueryType() { + return WildcardQueryBuilder.class; + } + + /** + * Converts a wildcard query to a Calcite LIKE expression. + *
+ * Validates field existence, checks for unsupported parameters, applies case-insensitive + * transformation if needed, and converts wildcard pattern to SQL LIKE pattern. + * + * @param query the wildcard query to convert + * @param ctx the conversion context with schema and RexBuilder + * @return RexNode representing {@code field LIKE 'pattern'} or {@code LOWER(field) LIKE 'pattern'} + * @throws ConversionException if field not found, or boost/rewrite parameters are set + */ + @Override + public RexNode convert(QueryBuilder query, ConversionContext ctx) throws ConversionException { + WildcardQueryBuilder wildcardQuery = (WildcardQueryBuilder) query; + + // Check for unsupported parameters + if (wildcardQuery.boost() != 1.0f) { + throw new ConversionException("Wildcard query parameter 'boost' is not supported"); + } + if (wildcardQuery.rewrite() != null) { + throw new ConversionException("Wildcard query parameter 'rewrite' is not supported"); + } + + String fieldName = wildcardQuery.fieldName(); + String pattern = wildcardQuery.value(); + boolean caseInsensitive = wildcardQuery.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); + pattern = pattern.toLowerCase(); + } + + // Convert wildcard pattern to LIKE pattern + String likePattern = convertWildcardToLike(pattern); + RexNode patternLiteral = ctx.getRexBuilder().makeLiteral(likePattern); + + // Return LIKE expression + return ctx.getRexBuilder().makeCall(SqlStdOperatorTable.LIKE, fieldRef, patternLiteral); + } + + /** + * Converts OpenSearch wildcard pattern to SQL LIKE pattern. + *
+ * Performs two operations: + *
+ * Character transformations: + *
+ * Example: {@code "a*b?c%d_e\\f"} → {@code "a%b_c\%d\_e\\\\f"} + * + * @param wildcardPattern the OpenSearch wildcard pattern with {@code *} and {@code ?} + * @return SQL LIKE pattern with {@code %} and {@code _} + */ + private String convertWildcardToLike(String wildcardPattern) { + StringBuilder result = new StringBuilder(); + for (int i = 0; i < wildcardPattern.length(); i++) { + char c = wildcardPattern.charAt(i); + switch (c) { + case '\\': + // Escape backslash + result.append("\\\\"); + break; + case '%': + // Escape SQL wildcard + result.append("\\%"); + break; + case '_': + // Escape SQL wildcard + result.append("\\_"); + break; + case '*': + // Convert to SQL any-characters wildcard + result.append('%'); + break; + case '?': + // Convert to SQL single-character wildcard + result.append('_'); + break; + default: + result.append(c); + } + } + return result.toString(); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/FilterConverterTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/FilterConverterTests.java index 6f4fa80afa5b0..9a527c87b2b8d 100644 --- a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/FilterConverterTests.java +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/FilterConverterTests.java @@ -72,7 +72,7 @@ public void testFilterPreservesRowType() throws ConversionException { } public void testUnsupportedQueryProducesFilterWithUnresolvedCondition() throws ConversionException { - SearchSourceBuilder source = new SearchSourceBuilder().query(QueryBuilders.wildcardQuery("name", "lap*")); + SearchSourceBuilder source = new SearchSourceBuilder().query(QueryBuilders.regexpQuery("name", "lap.*")); ConversionContext ctx = TestUtils.createContext(source); RelNode result = converter.convert(scan, ctx); diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/PrefixQueryTranslatorTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/PrefixQueryTranslatorTests.java new file mode 100644 index 0000000000000..6898cb42bc38c --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/PrefixQueryTranslatorTests.java @@ -0,0 +1,177 @@ +/* + * 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.rex.RexCall; +import org.apache.calcite.rex.RexLiteral; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.SqlKind; +import org.opensearch.dsl.TestUtils; +import org.opensearch.dsl.converter.ConversionContext; +import org.opensearch.dsl.converter.ConversionException; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.test.OpenSearchTestCase; + +public class PrefixQueryTranslatorTests extends OpenSearchTestCase { + + private final PrefixQueryTranslator translator = new PrefixQueryTranslator(); + private final ConversionContext ctx = TestUtils.createContext(); + + public void testReportsCorrectQueryType() { + assertEquals(org.opensearch.index.query.PrefixQueryBuilder.class, translator.getQueryType()); + } + + public void testBasicPrefixQuery() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.prefixQuery("name", "lap"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + assertEquals(SqlKind.LIKE, call.getKind()); + assertEquals(2, call.getOperands().size()); + + // Check pattern is "lap%" + RexNode pattern = call.getOperands().get(1); + assertTrue(pattern instanceof RexLiteral); + assertEquals("lap%", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testPrefixQueryWithEmptyString() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.prefixQuery("name", ""), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + assertEquals(SqlKind.LIKE, call.getKind()); + + // Empty prefix should match all: "%" + RexNode pattern = call.getOperands().get(1); + assertEquals("%", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testPrefixQueryCaseInsensitive() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.prefixQuery("name", "LAP").caseInsensitive(true), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + assertEquals(SqlKind.LIKE, call.getKind()); + + // First operand should be LOWER(field) + RexNode fieldExpr = call.getOperands().get(0); + assertTrue(fieldExpr instanceof RexCall); + assertEquals(SqlKind.OTHER_FUNCTION, ((RexCall) fieldExpr).getKind()); + assertEquals("LOWER", ((RexCall) fieldExpr).getOperator().getName()); + + // Pattern should be lowercased + RexNode pattern = call.getOperands().get(1); + assertEquals("lap%", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testPrefixQueryCaseSensitive() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.prefixQuery("name", "LAP").caseInsensitive(false), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + assertEquals(SqlKind.LIKE, call.getKind()); + + // First operand should be direct field reference (no LOWER) + RexNode fieldExpr = call.getOperands().get(0); + assertTrue(fieldExpr instanceof org.apache.calcite.rex.RexInputRef); + + // Pattern should preserve case + RexNode pattern = call.getOperands().get(1); + assertEquals("LAP%", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testPrefixQueryEscapesPercentSign() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.prefixQuery("name", "50%"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + + // % should be escaped to \% + RexNode pattern = call.getOperands().get(1); + assertEquals("50\\%%", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testPrefixQueryEscapesUnderscore() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.prefixQuery("name", "test_"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + + // _ should be escaped to \_ + RexNode pattern = call.getOperands().get(1); + assertEquals("test\\_%", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testPrefixQueryEscapesBackslash() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.prefixQuery("name", "path\\to"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + + // \ should be escaped to \\ + RexNode pattern = call.getOperands().get(1); + assertEquals("path\\\\to%", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testPrefixQueryWithDifferentFields() throws ConversionException { + // Test with different schema fields + assertNotNull(translator.convert(QueryBuilders.prefixQuery("name", "test"), ctx)); + assertNotNull(translator.convert(QueryBuilders.prefixQuery("brand", "test"), ctx)); + assertNotNull(translator.convert(QueryBuilders.prefixQuery("price", "100"), ctx)); + } + + public void testPrefixQueryThrowsForNonexistentField() { + ConversionException ex = expectThrows( + ConversionException.class, + () -> translator.convert(QueryBuilders.prefixQuery("nonexistent", "value"), ctx) + ); + assertTrue(ex.getMessage().contains("Field 'nonexistent' not found")); + } + + public void testPrefixQueryThrowsForBoostParameter() { + ConversionException ex = expectThrows( + ConversionException.class, + () -> translator.convert(QueryBuilders.prefixQuery("name", "lap").boost(2.0f), ctx) + ); + assertTrue(ex.getMessage().contains("boost")); + assertTrue(ex.getMessage().contains("not supported")); + } + + public void testPrefixQueryThrowsForRewriteParameter() { + ConversionException ex = expectThrows( + ConversionException.class, + () -> translator.convert(QueryBuilders.prefixQuery("name", "lap").rewrite("constant_score"), ctx) + ); + assertTrue(ex.getMessage().contains("rewrite")); + assertTrue(ex.getMessage().contains("not supported")); + } + + public void testPrefixQueryWithSpecialCharacters() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.prefixQuery("name", "test-123.abc"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + + // Special chars like - and . should not be escaped + RexNode pattern = call.getOperands().get(1); + assertEquals("test-123.abc%", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testPrefixQueryWithMultipleEscapes() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.prefixQuery("name", "a%b_c\\d"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + + // All special chars should be escaped + RexNode pattern = call.getOperands().get(1); + assertEquals("a\\%b\\_c\\\\d%", ((RexLiteral) pattern).getValueAs(String.class)); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/QueryRegistryTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/QueryRegistryTests.java index 6dd41dcad1f05..22bd4fab6912a 100644 --- a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/QueryRegistryTests.java +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/QueryRegistryTests.java @@ -16,7 +16,6 @@ import org.opensearch.dsl.converter.ConversionContext; import org.opensearch.dsl.converter.ConversionException; import org.opensearch.index.query.QueryBuilders; -import org.opensearch.index.query.WildcardQueryBuilder; import org.opensearch.test.OpenSearchTestCase; public class QueryRegistryTests extends OpenSearchTestCase { @@ -41,11 +40,11 @@ public void testResolvesMatchAllQuery() throws ConversionException { } public void testUnknownQueryTypeReturnsUnresolved() throws ConversionException { - RexNode result = registry.convert(QueryBuilders.wildcardQuery("name", "lap*"), ctx); + RexNode result = registry.convert(QueryBuilders.regexpQuery("name", "lap.*"), ctx); assertTrue(result instanceof UnresolvedQueryCall); UnresolvedQueryCall unresolved = (UnresolvedQueryCall) result; - assertTrue(unresolved.getQueryBuilder() instanceof WildcardQueryBuilder); + assertTrue(unresolved.getQueryBuilder() instanceof org.opensearch.index.query.RegexpQueryBuilder); } public void testEmptyRegistryReturnsUnresolvedForAnyQuery() throws ConversionException { diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/WildcardQueryTranslatorTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/WildcardQueryTranslatorTests.java new file mode 100644 index 0000000000000..492bdb0e3a3dd --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/WildcardQueryTranslatorTests.java @@ -0,0 +1,202 @@ +/* + * 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.rex.RexCall; +import org.apache.calcite.rex.RexLiteral; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.SqlKind; +import org.opensearch.dsl.TestUtils; +import org.opensearch.dsl.converter.ConversionContext; +import org.opensearch.dsl.converter.ConversionException; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.test.OpenSearchTestCase; + +public class WildcardQueryTranslatorTests extends OpenSearchTestCase { + + private final WildcardQueryTranslator translator = new WildcardQueryTranslator(); + private final ConversionContext ctx = TestUtils.createContext(); + + public void testReportsCorrectQueryType() { + assertEquals(org.opensearch.index.query.WildcardQueryBuilder.class, translator.getQueryType()); + } + + public void testWildcardWithAsterisk() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.wildcardQuery("name", "lap*"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + assertEquals(SqlKind.LIKE, call.getKind()); + + RexNode pattern = call.getOperands().get(1); + assertEquals("lap%", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testWildcardWithQuestionMark() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.wildcardQuery("name", "l?ptop"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + + RexNode pattern = call.getOperands().get(1); + assertEquals("l_ptop", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testWildcardWithBothWildcards() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.wildcardQuery("name", "l?p*"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + + RexNode pattern = call.getOperands().get(1); + assertEquals("l_p%", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testWildcardWithMultipleAsterisks() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.wildcardQuery("name", "*lap*top*"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + + RexNode pattern = call.getOperands().get(1); + assertEquals("%lap%top%", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testWildcardWithMultipleQuestionMarks() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.wildcardQuery("name", "l??top"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + + RexNode pattern = call.getOperands().get(1); + assertEquals("l__top", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testWildcardCaseInsensitive() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.wildcardQuery("name", "LAP*").caseInsensitive(true), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + + // First operand should be LOWER(field) + RexNode fieldExpr = call.getOperands().get(0); + assertTrue(fieldExpr instanceof RexCall); + assertEquals("LOWER", ((RexCall) fieldExpr).getOperator().getName()); + + // Pattern should be lowercased + RexNode pattern = call.getOperands().get(1); + assertEquals("lap%", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testWildcardEscapesPercent() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.wildcardQuery("name", "50%*"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + + RexNode pattern = call.getOperands().get(1); + assertEquals("50\\%%", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testWildcardEscapesUnderscore() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.wildcardQuery("name", "test_*"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + + RexNode pattern = call.getOperands().get(1); + assertEquals("test\\_%", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testWildcardEscapesBackslash() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.wildcardQuery("name", "path\\*"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + + RexNode pattern = call.getOperands().get(1); + assertEquals("path\\\\%", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testWildcardWithNoWildcards() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.wildcardQuery("name", "laptop"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + + RexNode pattern = call.getOperands().get(1); + assertEquals("laptop", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testWildcardWithOnlyAsterisk() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.wildcardQuery("name", "*"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + + RexNode pattern = call.getOperands().get(1); + assertEquals("%", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testWildcardWithOnlyQuestionMark() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.wildcardQuery("name", "?"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + + RexNode pattern = call.getOperands().get(1); + assertEquals("_", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testWildcardThrowsForNonexistentField() { + ConversionException ex = expectThrows( + ConversionException.class, + () -> translator.convert(QueryBuilders.wildcardQuery("nonexistent", "val*"), ctx) + ); + assertTrue(ex.getMessage().contains("Field 'nonexistent' not found")); + } + + public void testWildcardThrowsForBoostParameter() { + ConversionException ex = expectThrows( + ConversionException.class, + () -> translator.convert(QueryBuilders.wildcardQuery("name", "lap*").boost(2.0f), ctx) + ); + assertTrue(ex.getMessage().contains("boost")); + assertTrue(ex.getMessage().contains("not supported")); + } + + public void testWildcardThrowsForRewriteParameter() { + ConversionException ex = expectThrows( + ConversionException.class, + () -> translator.convert(QueryBuilders.wildcardQuery("name", "lap*").rewrite("constant_score"), ctx) + ); + assertTrue(ex.getMessage().contains("rewrite")); + assertTrue(ex.getMessage().contains("not supported")); + } + + public void testWildcardWithComplexPattern() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.wildcardQuery("name", "a*b?c*d"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + + RexNode pattern = call.getOperands().get(1); + assertEquals("a%b_c%d", ((RexLiteral) pattern).getValueAs(String.class)); + } + + public void testWildcardWithMixedEscaping() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.wildcardQuery("name", "a%b_c\\d*e?"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + + RexNode pattern = call.getOperands().get(1); + assertEquals("a\\%b\\_c\\\\d%e_", ((RexLiteral) pattern).getValueAs(String.class)); + } +}