Skip to content

Commit e6ace3d

Browse files
authored
Extended UT Framework for E2E Query conversion (#21227)
* Extended UT Framework for E2E Query conversion (#21060) Changes to test the end to end DSL query conversion without OpenSearch cluster. Currently adds support for match_all and terms with agg for the initial commit. Signed-off-by: Suresh N S <nssuresh@amazon.com> * Addressing the comments from the PR revision Signed-off-by: Suresh N S <nssuresh@amazon.com> * Removed the README.md file Signed-off-by: Suresh N S <nssuresh@amazon.com> * Fixing spotless check failures Signed-off-by: Suresh N S <nssuresh@amazon.com> --------- Signed-off-by: Suresh N S <nssuresh@amazon.com>
1 parent f7bd4f4 commit e6ace3d

8 files changed

Lines changed: 672 additions & 0 deletions

File tree

sandbox/plugins/dsl-query-executor/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ dependencies {
3939

4040
testImplementation project(':test:framework')
4141
testImplementation "org.mockito:mockito-core:${versions.mockito}"
42+
testImplementation "com.fasterxml.jackson.core:jackson-databind:${versions.jackson_databind}"
43+
testImplementation "com.fasterxml.jackson.core:jackson-annotations:${versions.jackson_annotations}"
4244

4345
internalClusterTestImplementation project(':server')
4446
internalClusterTestImplementation project(':test:framework')

sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/SearchSourceConverterTests.java

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,39 @@
99
package org.opensearch.dsl.converter;
1010

1111
import org.apache.calcite.jdbc.CalciteSchema;
12+
import org.apache.calcite.rel.RelNode;
1213
import org.apache.calcite.rel.logical.LogicalSort;
1314
import org.apache.calcite.rel.logical.LogicalTableScan;
1415
import org.apache.calcite.rel.type.RelDataType;
1516
import org.apache.calcite.rel.type.RelDataTypeFactory;
1617
import org.apache.calcite.schema.SchemaPlus;
1718
import org.apache.calcite.schema.impl.AbstractTable;
1819
import org.apache.calcite.sql.type.SqlTypeName;
20+
import org.opensearch.common.settings.Settings;
21+
import org.opensearch.common.xcontent.json.JsonXContent;
22+
import org.opensearch.core.xcontent.DeprecationHandler;
23+
import org.opensearch.core.xcontent.NamedXContentRegistry;
24+
import org.opensearch.core.xcontent.XContentParser;
1925
import org.opensearch.dsl.executor.QueryPlans;
26+
import org.opensearch.dsl.golden.CalciteTestInfra;
27+
import org.opensearch.dsl.golden.GoldenFileLoader;
28+
import org.opensearch.dsl.golden.GoldenTestCase;
29+
import org.opensearch.search.SearchModule;
2030
import org.opensearch.search.aggregations.BucketOrder;
2131
import org.opensearch.search.aggregations.bucket.terms.TermsAggregationBuilder;
2232
import org.opensearch.search.aggregations.metrics.AvgAggregationBuilder;
2333
import org.opensearch.search.builder.SearchSourceBuilder;
2434
import org.opensearch.test.OpenSearchTestCase;
2535

36+
import java.io.IOException;
37+
import java.net.URL;
38+
import java.nio.file.Files;
39+
import java.nio.file.Path;
40+
import java.util.ArrayList;
41+
import java.util.Collections;
2642
import java.util.List;
43+
import java.util.Map;
44+
import java.util.stream.Collectors;
2745

2846
public class SearchSourceConverterTests extends OpenSearchTestCase {
2947

@@ -128,4 +146,78 @@ public void testMetricOnlyAggPlanHasNoPostAggSort() throws ConversionException {
128146
// Metric-only agg has no bucket orders, so no LogicalSort wrapper
129147
assertFalse(plans.get(QueryPlans.Type.AGGREGATION).get(0).relNode() instanceof LogicalSort);
130148
}
149+
150+
// ---- Golden file driven RelNode generation tests ----
151+
152+
/**
153+
* Auto-discovers all golden JSON files and validates that each inputDsl
154+
* produces the expected RelNode plan via SearchSourceConverter.convert().
155+
* Adding a new test case only requires adding a new JSON file — no new
156+
* Java method needed.
157+
*/
158+
public void testGoldenFileRelNodeGeneration() throws Exception {
159+
URL goldenDir = getClass().getClassLoader().getResource("golden");
160+
assertNotNull("Golden file resource directory not found", goldenDir);
161+
162+
List<Path> goldenFiles;
163+
try (var stream = Files.list(Path.of(goldenDir.toURI()))) {
164+
goldenFiles = stream.filter(p -> p.toString().endsWith(".json")).collect(Collectors.toList());
165+
}
166+
assertFalse("No golden files found", goldenFiles.isEmpty());
167+
168+
List<String> failures = new ArrayList<>();
169+
for (Path file : goldenFiles) {
170+
String fileName = file.getFileName().toString();
171+
try {
172+
GoldenTestCase tc = GoldenFileLoader.load(fileName);
173+
CalciteTestInfra.InfraResult infra = CalciteTestInfra.buildFromMapping(tc.getIndexName(), tc.getIndexMapping());
174+
175+
SearchSourceBuilder searchSource = parseSearchSource(tc.getInputDsl());
176+
SearchSourceConverter conv = new SearchSourceConverter(infra.schema());
177+
QueryPlans plans = conv.convert(searchSource, tc.getIndexName());
178+
179+
QueryPlans.Type expectedType = QueryPlans.Type.valueOf(tc.getPlanType());
180+
List<QueryPlans.QueryPlan> matchingPlans = plans.get(expectedType);
181+
if (matchingPlans.isEmpty()) {
182+
failures.add(fileName + ": No " + expectedType + " plan produced");
183+
continue;
184+
}
185+
186+
RelNode relNode = matchingPlans.get(0).relNode();
187+
String actualPlan = relNode.explain().trim();
188+
String expectedPlan = String.join("\n", tc.getExpectedRelNodePlan());
189+
190+
if (!expectedPlan.equals(actualPlan)) {
191+
failures.add(fileName + ": RelNode plan mismatch\n Expected: " + expectedPlan + "\n Actual: " + actualPlan);
192+
}
193+
194+
List<String> actualFields = relNode.getRowType().getFieldNames();
195+
if (!tc.getMockResultFieldNames().equals(actualFields)) {
196+
failures.add(
197+
fileName + ": Field names mismatch\n Expected: " + tc.getMockResultFieldNames() + "\n Actual: " + actualFields
198+
);
199+
}
200+
} catch (Exception e) {
201+
failures.add(fileName + ": " + e.getClass().getSimpleName() + " - " + e.getMessage());
202+
}
203+
}
204+
205+
if (!failures.isEmpty()) {
206+
fail("Golden file RelNode generation failures:\n" + String.join("\n", failures));
207+
}
208+
}
209+
210+
private SearchSourceBuilder parseSearchSource(Map<String, Object> inputDsl) throws IOException {
211+
String json;
212+
try (var builder = JsonXContent.contentBuilder()) {
213+
builder.map(inputDsl);
214+
json = builder.toString();
215+
}
216+
NamedXContentRegistry registry = new NamedXContentRegistry(
217+
new SearchModule(Settings.EMPTY, Collections.emptyList()).getNamedXContents()
218+
);
219+
try (XContentParser parser = JsonXContent.jsonXContent.createParser(registry, DeprecationHandler.IGNORE_DEPRECATIONS, json)) {
220+
return SearchSourceBuilder.fromXContent(parser);
221+
}
222+
}
131223
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* The OpenSearch Contributors require contributions made to
5+
* this file be licensed under the Apache-2.0 license or a
6+
* compatible open source license.
7+
*/
8+
9+
package org.opensearch.dsl.golden;
10+
11+
import org.apache.calcite.config.CalciteConnectionConfigImpl;
12+
import org.apache.calcite.jdbc.CalciteSchema;
13+
import org.apache.calcite.plan.RelOptCluster;
14+
import org.apache.calcite.plan.RelOptTable;
15+
import org.apache.calcite.plan.hep.HepPlanner;
16+
import org.apache.calcite.plan.hep.HepProgram;
17+
import org.apache.calcite.prepare.CalciteCatalogReader;
18+
import org.apache.calcite.rel.type.RelDataType;
19+
import org.apache.calcite.rel.type.RelDataTypeFactory;
20+
import org.apache.calcite.rel.type.RelDataTypeSystem;
21+
import org.apache.calcite.rex.RexBuilder;
22+
import org.apache.calcite.schema.SchemaPlus;
23+
import org.apache.calcite.schema.impl.AbstractTable;
24+
import org.apache.calcite.sql.type.SqlTypeFactoryImpl;
25+
import org.apache.calcite.sql.type.SqlTypeName;
26+
27+
import java.util.Collections;
28+
import java.util.List;
29+
import java.util.Map;
30+
import java.util.Objects;
31+
import java.util.Properties;
32+
33+
/**
34+
* Builds Calcite planning infrastructure from a golden file's index mapping.
35+
*
36+
* <p>Mirrors the pattern in {@code TestUtils} and {@code SearchSourceConverter}'s
37+
* constructor, but constructs the schema dynamically from the golden file's
38+
* {@code indexMapping} field instead of using a hardcoded schema.
39+
*/
40+
public class CalciteTestInfra {
41+
42+
private CalciteTestInfra() {}
43+
44+
/**
45+
* Builds a complete Calcite infrastructure from a golden file's index mapping.
46+
*
47+
* @param indexName the index name to register in the schema
48+
* @param indexMapping field name → SQL type name (e.g. "VARCHAR", "INTEGER")
49+
* @return an {@link InfraResult} containing the cluster, table, and schema
50+
* @throws IllegalArgumentException if indexMapping contains an unsupported type
51+
*/
52+
public static InfraResult buildFromMapping(String indexName, Map<String, String> indexMapping) {
53+
Objects.requireNonNull(indexName, "indexName must not be null");
54+
Objects.requireNonNull(indexMapping, "indexMapping must not be null");
55+
56+
RelDataTypeFactory typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT);
57+
HepPlanner planner = new HepPlanner(HepProgram.builder().build());
58+
RelOptCluster cluster = RelOptCluster.create(planner, new RexBuilder(typeFactory));
59+
60+
SchemaPlus schema = CalciteSchema.createRootSchema(true).plus();
61+
schema.add(indexName, new AbstractTable() {
62+
@Override
63+
public RelDataType getRowType(RelDataTypeFactory tf) {
64+
RelDataTypeFactory.Builder builder = tf.builder();
65+
for (Map.Entry<String, String> entry : indexMapping.entrySet()) {
66+
SqlTypeName sqlType = toSqlTypeName(entry.getValue());
67+
builder.add(entry.getKey(), tf.createTypeWithNullability(tf.createSqlType(sqlType), true));
68+
}
69+
return builder.build();
70+
}
71+
});
72+
73+
CalciteCatalogReader reader = new CalciteCatalogReader(
74+
CalciteSchema.from(schema),
75+
Collections.singletonList(""),
76+
typeFactory,
77+
new CalciteConnectionConfigImpl(new Properties())
78+
);
79+
RelOptTable table = Objects.requireNonNull(reader.getTable(List.of(indexName)), "Table not found in schema: " + indexName);
80+
81+
return new InfraResult(cluster, table, schema);
82+
}
83+
84+
/**
85+
* Maps a golden file type string to a Calcite {@link SqlTypeName}.
86+
*
87+
* @throws IllegalArgumentException for unsupported type strings
88+
*/
89+
private static SqlTypeName toSqlTypeName(String goldenType) {
90+
switch (goldenType) {
91+
case "VARCHAR":
92+
return SqlTypeName.VARCHAR;
93+
case "INTEGER":
94+
return SqlTypeName.INTEGER;
95+
case "BIGINT":
96+
return SqlTypeName.BIGINT;
97+
case "DOUBLE":
98+
return SqlTypeName.DOUBLE;
99+
case "FLOAT":
100+
return SqlTypeName.FLOAT;
101+
case "BOOLEAN":
102+
return SqlTypeName.BOOLEAN;
103+
case "DATE":
104+
return SqlTypeName.DATE;
105+
case "TIMESTAMP":
106+
return SqlTypeName.TIMESTAMP;
107+
default:
108+
throw new IllegalArgumentException("Unsupported SQL type in golden file indexMapping: " + goldenType);
109+
}
110+
}
111+
112+
/** Result record containing the Calcite infrastructure built from a golden file mapping. */
113+
public record InfraResult(RelOptCluster cluster, RelOptTable table, SchemaPlus schema) {
114+
}
115+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* The OpenSearch Contributors require contributions made to
5+
* this file be licensed under the Apache-2.0 license or a
6+
* compatible open source license.
7+
*/
8+
9+
package org.opensearch.dsl.golden;
10+
11+
import com.fasterxml.jackson.databind.ObjectMapper;
12+
13+
import org.opensearch.dsl.executor.QueryPlans;
14+
15+
import java.io.IOException;
16+
import java.io.InputStream;
17+
import java.nio.file.Files;
18+
import java.nio.file.Path;
19+
20+
/**
21+
* Loads and validates golden file test cases.
22+
*
23+
* <p>Each golden file is a self-contained JSON document parsed into a
24+
* {@link GoldenTestCase}. Required fields are validated after parsing;
25+
* aggregation test cases must additionally include {@code aggregationMetadata}.
26+
*/
27+
public class GoldenFileLoader {
28+
29+
private static final ObjectMapper MAPPER = new ObjectMapper();
30+
private static final String RESOURCE_DIR = "golden/";
31+
32+
private GoldenFileLoader() {}
33+
34+
/**
35+
* Loads a golden file by name from the classpath resource directory
36+
* {@code src/test/resources/golden/}.
37+
*
38+
* @param goldenFileName file name (e.g. {@code "term_query_hits.json"})
39+
* @return parsed and validated test case
40+
* @throws IllegalArgumentException if the file is missing, malformed, or
41+
* has missing required fields
42+
*/
43+
public static GoldenTestCase load(String goldenFileName) {
44+
String resourcePath = RESOURCE_DIR + goldenFileName;
45+
try (InputStream is = GoldenFileLoader.class.getClassLoader().getResourceAsStream(resourcePath)) {
46+
if (is == null) {
47+
throw new IllegalArgumentException("Golden file not found on classpath: " + resourcePath);
48+
}
49+
GoldenTestCase testCase = MAPPER.readValue(is, GoldenTestCase.class);
50+
validate(testCase, Path.of(resourcePath));
51+
return testCase;
52+
} catch (IOException e) {
53+
throw new IllegalArgumentException("Failed to parse golden file: " + resourcePath, e);
54+
}
55+
}
56+
57+
/**
58+
* Loads a golden file from an absolute or relative file-system path.
59+
*
60+
* @param goldenFilePath path to the JSON golden file
61+
* @return parsed and validated test case
62+
* @throws IllegalArgumentException if the file is malformed or has missing
63+
* required fields
64+
*/
65+
public static GoldenTestCase load(Path goldenFilePath) {
66+
try (InputStream is = Files.newInputStream(goldenFilePath)) {
67+
GoldenTestCase testCase = MAPPER.readValue(is, GoldenTestCase.class);
68+
validate(testCase, goldenFilePath);
69+
return testCase;
70+
} catch (IOException e) {
71+
throw new IllegalArgumentException("Failed to parse golden file: " + goldenFilePath, e);
72+
}
73+
}
74+
75+
/**
76+
* Validates that all required fields are present in the parsed test case.
77+
* Throws {@link IllegalArgumentException} identifying the file and the
78+
* missing field.
79+
*/
80+
private static void validate(GoldenTestCase testCase, Path filePath) {
81+
requireNonNull(testCase.getTestName(), "testName", filePath);
82+
requireNonNull(testCase.getIndexName(), "indexName", filePath);
83+
requireNonNull(testCase.getIndexMapping(), "indexMapping", filePath);
84+
requireNonNull(testCase.getInputDsl(), "inputDsl", filePath);
85+
requireNonNull(testCase.getExpectedRelNodePlan(), "expectedRelNodePlan", filePath);
86+
requireNonNull(testCase.getMockResultFieldNames(), "mockResultFieldNames", filePath);
87+
requireNonNull(testCase.getMockResultRows(), "mockResultRows", filePath);
88+
requireNonNull(testCase.getExpectedOutputDsl(), "expectedOutputDsl", filePath);
89+
requireNonNull(testCase.getPlanType(), "planType", filePath);
90+
try {
91+
QueryPlans.Type.valueOf(testCase.getPlanType());
92+
} catch (IllegalArgumentException e) {
93+
throw new IllegalArgumentException("Golden file " + filePath + " has invalid planType: " + testCase.getPlanType());
94+
}
95+
}
96+
97+
private static void requireNonNull(Object value, String fieldName, Path filePath) {
98+
if (value == null) {
99+
throw new IllegalArgumentException("Golden file " + filePath + " missing required field: " + fieldName);
100+
}
101+
}
102+
}

0 commit comments

Comments
 (0)