From 77eff9fcbfc24628fa8fb44a74f3ce74ca099a9c Mon Sep 17 00:00:00 2001 From: Siri Gaddameedi Date: Wed, 29 Jul 2026 11:18:37 +0000 Subject: [PATCH] Add DSL query-type parquet REST test suite (initial) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a standalone REST integration module that validates parquet/ composite query behavior against known-correct answers generated on a vanilla (plain Lucene) index. Each query type is a folder under datasets// (mapping.json + bulk.json + dsl/q1.json + dsl/expected/q1.json). DslQueryTypesIT provisions the dataset into a parquet-backed index, runs the query, and validates the response against the committed expected answer via DslResponseValidator (order-independent, numeric tolerance). The suite runs against an already-running server over HTTP, so the test JVM loads no plugins. Seed the suite with two query types plus single-valued-tags variants: - term, bool: docs store `tags` as a multi-valued array, which parquet rejects at ingest ("Cannot accept multiple values for field [tags]"). - term_scalar, bool_scalar: identical mapping and query but `tags` is a single scalar value, which parquet accepts — isolating the multi-value array as the sole cause and confirming the query type itself works. Also includes DslTermQueryIT, a focused term-query correctness test. Signed-off-by: Siri Gaddameedi --- sandbox/qa/dsl-query-types/README.md | 91 ++++++ sandbox/qa/dsl-query-types/build.gradle | 62 ++++ .../org/opensearch/dsl/types/Dataset.java | 57 ++++ .../dsl/types/DatasetProvisioner.java | 121 ++++++++ .../dsl/types/DatasetQueryRunner.java | 133 +++++++++ .../dsl/types/DslQueryTypeCatalog.java | 70 +++++ .../opensearch/dsl/types/DslQueryTypesIT.java | 130 +++++++++ .../dsl/types/DslResponseValidator.java | 264 ++++++++++++++++++ .../opensearch/dsl/types/DslTermQueryIT.java | 174 ++++++++++++ .../test/resources/datasets/bool/bulk.json | 8 + .../datasets/bool/dsl/expected/q1.json | 8 + .../test/resources/datasets/bool/dsl/q1.json | 20 ++ .../test/resources/datasets/bool/mapping.json | 28 ++ .../resources/datasets/bool_scalar/bulk.json | 8 + .../datasets/bool_scalar/dsl/expected/q1.json | 8 + .../datasets/bool_scalar/dsl/q1.json | 20 ++ .../datasets/bool_scalar/mapping.json | 28 ++ .../test/resources/datasets/people/bulk.json | 10 + .../resources/datasets/people/mapping.json | 15 + .../test/resources/datasets/term/bulk.json | 8 + .../datasets/term/dsl/expected/q1.json | 8 + .../test/resources/datasets/term/dsl/q1.json | 7 + .../test/resources/datasets/term/mapping.json | 28 ++ .../resources/datasets/term_scalar/bulk.json | 8 + .../datasets/term_scalar/dsl/expected/q1.json | 8 + .../datasets/term_scalar/dsl/q1.json | 7 + .../datasets/term_scalar/mapping.json | 28 ++ 27 files changed, 1357 insertions(+) create mode 100644 sandbox/qa/dsl-query-types/README.md create mode 100644 sandbox/qa/dsl-query-types/build.gradle create mode 100644 sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/Dataset.java create mode 100644 sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DatasetProvisioner.java create mode 100644 sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DatasetQueryRunner.java create mode 100644 sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DslQueryTypeCatalog.java create mode 100644 sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DslQueryTypesIT.java create mode 100644 sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DslResponseValidator.java create mode 100644 sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DslTermQueryIT.java create mode 100644 sandbox/qa/dsl-query-types/src/test/resources/datasets/bool/bulk.json create mode 100644 sandbox/qa/dsl-query-types/src/test/resources/datasets/bool/dsl/expected/q1.json create mode 100644 sandbox/qa/dsl-query-types/src/test/resources/datasets/bool/dsl/q1.json create mode 100644 sandbox/qa/dsl-query-types/src/test/resources/datasets/bool/mapping.json create mode 100644 sandbox/qa/dsl-query-types/src/test/resources/datasets/bool_scalar/bulk.json create mode 100644 sandbox/qa/dsl-query-types/src/test/resources/datasets/bool_scalar/dsl/expected/q1.json create mode 100644 sandbox/qa/dsl-query-types/src/test/resources/datasets/bool_scalar/dsl/q1.json create mode 100644 sandbox/qa/dsl-query-types/src/test/resources/datasets/bool_scalar/mapping.json create mode 100644 sandbox/qa/dsl-query-types/src/test/resources/datasets/people/bulk.json create mode 100644 sandbox/qa/dsl-query-types/src/test/resources/datasets/people/mapping.json create mode 100644 sandbox/qa/dsl-query-types/src/test/resources/datasets/term/bulk.json create mode 100644 sandbox/qa/dsl-query-types/src/test/resources/datasets/term/dsl/expected/q1.json create mode 100644 sandbox/qa/dsl-query-types/src/test/resources/datasets/term/dsl/q1.json create mode 100644 sandbox/qa/dsl-query-types/src/test/resources/datasets/term/mapping.json create mode 100644 sandbox/qa/dsl-query-types/src/test/resources/datasets/term_scalar/bulk.json create mode 100644 sandbox/qa/dsl-query-types/src/test/resources/datasets/term_scalar/dsl/expected/q1.json create mode 100644 sandbox/qa/dsl-query-types/src/test/resources/datasets/term_scalar/dsl/q1.json create mode 100644 sandbox/qa/dsl-query-types/src/test/resources/datasets/term_scalar/mapping.json diff --git a/sandbox/qa/dsl-query-types/README.md b/sandbox/qa/dsl-query-types/README.md new file mode 100644 index 0000000000000..1bb547480116c --- /dev/null +++ b/sandbox/qa/dsl-query-types/README.md @@ -0,0 +1,91 @@ +# DSL Query-Type REST Tests + +Per-query-type integration tests for the parquet/composite data format, run against a **live, external** +OpenSearch server (your `./gradlew run` sandbox). One resource folder per DSL query type; each is +provisioned into a parquet-backed index over REST and its response validated against a committed +expected answer. + +This module seeds the suite with a small set of query types. It is intended to grow one folder at a +time as more query types are covered. + +## Why standalone (and why REST) + +The DSL/analytics/parquet plugin stack targets **JDK 25**. An in-JVM `internalClusterTest` would load +those plugins into the test JVM (pinning it to JDK 25); these tests instead talk HTTP to an +already-running server via `OpenSearchRestTestCase`, so the test JVM loads no plugins. + +The module applies **only** `opensearch.standalone-rest-test` and depends on nothing but +`:test:framework`. The dataset runner infrastructure (`Dataset` / `DatasetProvisioner` / +`DatasetQueryRunner`) is a self-contained local copy in this module. + +## Layout — one resource folder per query type + +Each query type is a folder under `src/test/resources/datasets//`: + +``` +datasets/ + term/ + mapping.json # index mapping (keeps the literal "number_of_shards" token) + bulk.json # sample docs (NDJSON) + dsl/q1.json # the DSL query body (auto-discovered as q.json) + dsl/expected/q1.json # the expected answer (see below) +``` + +`DatasetProvisioner` splices the canonical parquet/composite settings into each `mapping.json` at +provision time — this is the **single place** those settings live: + +``` +"index.pluggable.dataformat.enabled": true, +"index.pluggable.dataformat": "composite", +"index.composite.primary_data_format": "parquet", +"index.composite.secondary_data_formats": "lucene" +``` + +## Java + +``` +Dataset.java ← dataset descriptor (folder name == index name == query type) +DatasetProvisioner.java ← reads mapping/bulk, injects parquet settings, creates + ingests +DatasetQueryRunner.java ← auto-discovers dsl/q*.json +DslQueryTypeCatalog.java ← one entry per type: type, family, Dataset +DslQueryTypesIT.java ← provisions each type + runs its query, validates against the expected answer +DslResponseValidator.java ← compares a response against the committed expected answer +DslTermQueryIT.java ← focused term-query correctness test (dataset: people) +``` + +## What is asserted + +Each expected answer (`datasets//dsl/expected/q.json`) is the **true answer** produced on a +vanilla OpenSearch index (default Lucene backend, no parquet settings) and committed. `DslQueryTypesIT` +provisions the dataset with parquet enabled, runs the query, and validates the parquet response against +that expected answer via `DslResponseValidator` (order-independent, numeric tolerance). A type is green +only when the parquet response matches the true answer, so any place parquet deviates surfaces as red. + +### Single-valued-tags variants + +`term` / `bool` store `tags` as a multi-valued array, which parquet rejects at ingest ("Cannot accept +multiple values for field [tags] of type [keyword]"). The `*_scalar` variants are identical except +`tags` is a single scalar value, which parquet accepts — isolating the multi-value array as the sole +cause and confirming the query type itself is supported once `tags` is single-valued. + +## Running + +Start the server with the plugin stack, then: + +```bash +# Default: localhost:9200 (cluster runTask) +./gradlew :sandbox:qa:dsl-query-types:restTest -Dsandbox.enabled=true + +# Custom cluster +./gradlew :sandbox:qa:dsl-query-types:restTest -Dsandbox.enabled=true -PrestCluster=host:port + +# Just this test +./gradlew :sandbox:qa:dsl-query-types:restTest -Dsandbox.enabled=true \ + --tests "org.opensearch.dsl.types.DslQueryTypesIT" +``` + +## Adding a query type + +Create `datasets//` with `mapping.json` + `bulk.json` + `dsl/q1.json` + `dsl/expected/q1.json` +(the expected answer generated on a vanilla index), and add a matching `e("", family)` entry to +`DslQueryTypeCatalog.all()`. Additional queries per type are auto-discovered as `dsl/q2.json`, …. diff --git a/sandbox/qa/dsl-query-types/build.gradle b/sandbox/qa/dsl-query-types/build.gradle new file mode 100644 index 0000000000000..921d8625eec60 --- /dev/null +++ b/sandbox/qa/dsl-query-types/build.gradle @@ -0,0 +1,62 @@ +/* + * 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. + */ + +/* + * Per-query-type DSL REST integration tests for the dsl-query-executor plugin. + * + * Runs against an EXTERNAL, already-running OpenSearch server (the one started with + * `./gradlew run -Dsandbox.enabled=true -PinstalledPlugins=[...]` with the analytics/DataFusion/DSL + * stack). Tests talk to it purely over HTTP via OpenSearchRestTestCase, so the test JVM never loads + * the plugins — the server runs on JDK 25 with the full stack, the tests just send _search requests. + * + * Deliberately STANDALONE: it applies only opensearch.standalone-rest-test and depends on nothing but + * :test:framework (added by that plugin). It does NOT depend on sandbox/qa/analytics-engine-rest or + * sandbox/plugins/test-ppl-frontend, so it is unaffected by their build-classpath state. + * + * Each query type is a folder under src/test/resources/datasets// (mapping.json + bulk.json + + * dsl/q{N}.json + dsl/expected/q{N}.json golden). DslQueryTypeCatalog lists the types; DslQueryTypesIT + * turns every (type, queryNumber) pair into an independent parameterized test that provisions, queries, + * and validates against the golden. + */ + +apply plugin: 'opensearch.standalone-rest-test' + +// The DSL/analytics plugin stack the server runs targets JDK 25; compile the tests at the same level. +java { + sourceCompatibility = JavaVersion.toVersion(25) + targetCompatibility = JavaVersion.toVersion(25) +} + +// No dependencies block: opensearch.standalone-rest-test already puts :test:framework on the test +// classpath, which provides OpenSearchRestTestCase, the REST client, entityAsMap, and assertions. +// Everything else goes to the server over HTTP. + +// ── External-cluster runner ────────────────────────────────────────────────── +// Run against a cluster you started yourself (no testClusters lifecycle): +// ./gradlew :sandbox:qa:dsl-query-types:restTest -Dsandbox.enabled=true +// ./gradlew :sandbox:qa:dsl-query-types:restTest -Dsandbox.enabled=true -PrestCluster=host:port +// Defaults to localhost:9200 (the `./gradlew run` server, cluster name runTask). +// +// Goldens (datasets//dsl/expected/q.json) are the TRUE answer — generated out-of-band by +// running each dataset on a VANILLA OpenSearch index (default Lucene backend, no parquet settings) and +// committed. This suite only validates the parquet response against them. +tasks.register('restTest', Test) { + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + include '**/*IT.class' + def cluster = findProperty('restCluster') ?: 'localhost:9200' + def clusterName = findProperty('restClusterName') ?: 'runTask' + systemProperty 'tests.rest.cluster', cluster + systemProperty 'tests.cluster', cluster + systemProperty 'tests.clustername', clusterName + systemProperty 'tests.security.manager', 'false' + systemProperty 'tests.rest.load_packaged', 'false' + testLogging { + exceptionFormat = 'full' + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/Dataset.java b/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/Dataset.java new file mode 100644 index 0000000000000..984b9fc97d02b --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/Dataset.java @@ -0,0 +1,57 @@ +/* + * 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.types; + +/** + * Descriptor for a per-query-type test dataset loaded from {@code resources/datasets/{name}/}. + *

+ * A dataset consists of: + *

    + *
  • {@code mapping.json} — index mapping + settings (keeps the literal {@code "number_of_shards"} + * token so {@link DatasetProvisioner} can splice in the parquet/composite settings)
  • + *
  • {@code bulk.json} — bulk-indexable documents (NDJSON)
  • + *
  • {@code dsl/q{N}.json} — DSL query bodies (auto-discovered by {@link DatasetQueryRunner})
  • + *
+ *

+ * A local copy tailored to this standalone module — it does not depend on the analytics-engine-rest + * test infrastructure (which drags in the test-ppl-frontend build). + */ +public final class Dataset { + + /** The dataset name == directory under {@code resources/datasets/}. */ + public final String name; + + /** The index name to provision the dataset into. */ + public final String indexName; + + public Dataset(String name, String indexName) { + this.name = name; + this.indexName = indexName; + } + + /** Path to the mapping resource. */ + public String mappingResourcePath() { + return "datasets/" + name + "/mapping.json"; + } + + /** Path to the bulk data resource. */ + public String bulkResourcePath() { + return "datasets/" + name + "/bulk.json"; + } + + /** Path to a query resource for the given language and query number. */ + public String queryResourcePath(String language, String extension, int queryNumber) { + return "datasets/" + name + "/" + language + "/q" + queryNumber + "." + extension; + } + + /** Path to the golden expected-response resource for the given language and query number. */ + public String expectedResponseResourcePath(String language, int queryNumber) { + return "datasets/" + name + "/" + language + "/expected/q" + queryNumber + ".json"; + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DatasetProvisioner.java b/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DatasetProvisioner.java new file mode 100644 index 0000000000000..a9bad341cddf8 --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DatasetProvisioner.java @@ -0,0 +1,121 @@ +/* + * 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.types; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.client.Request; +import org.opensearch.client.Response; +import org.opensearch.client.ResponseException; +import org.opensearch.client.RestClient; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * Provisions a {@link Dataset} into a parquet/composite index over REST: reads {@code mapping.json} + * and {@code bulk.json} from the dataset's resource dir, splices in the parquet data-format settings, + * creates the index, and bulk-ingests the docs. + * + *

The injected settings are the canonical form required by the DSL/analytics engine: + *

+ *   "index.pluggable.dataformat.enabled": true,
+ *   "index.pluggable.dataformat": "composite",
+ *   "index.composite.primary_data_format": "parquet",
+ *   "index.composite.secondary_data_formats": "lucene"
+ * 
+ * so this is the single place index-creation settings live for the whole module. + * + *

Local copy tailored to this standalone module (no dependency on analytics-engine-rest). + */ +public final class DatasetProvisioner { + + private static final Logger logger = LogManager.getLogger(DatasetProvisioner.class); + + private DatasetProvisioner() {} + + /** Provision the dataset into its parquet index. Throws (propagating the HTTP error) if the + * backend rejects the mapping — the expected path for geo/nested field shapes. */ + public static void provision(RestClient client, Dataset dataset) throws IOException { + // Delete if it already exists — ignore "not found". + try { + client.performRequest(new Request("DELETE", "/" + dataset.indexName)); + } catch (ResponseException e) { + // index may not exist — ignore + } + + String mapping = loadResource(dataset.mappingResourcePath()); + Request createIndex = new Request("PUT", "/" + dataset.indexName); + createIndex.setJsonEntity(injectParquetSettings(mapping)); // may throw ResponseException (e.g. geo/nested → HTTP 400) + client.performRequest(createIndex); + + String bulk = loadResource(dataset.bulkResourcePath()); + Request bulkRequest = new Request("POST", "/" + dataset.indexName + "/_bulk"); + bulkRequest.addParameter("refresh", "true"); + bulkRequest.setJsonEntity(bulk); + bulkRequest.setOptions(bulkRequest.getOptions().toBuilder().addHeader("Content-Type", "application/x-ndjson").build()); + Response bulkResponse = client.performRequest(bulkRequest); + assertEquals("bulk ingest failed for " + dataset.indexName, 200, bulkResponse.getStatusLine().getStatusCode()); + // The _bulk API returns HTTP 200 even when individual items fail (e.g. a parquet/composite + // index sets index.append_only.enabled, which rejects custom document ids). Fail loudly on + // per-item errors so a silent zero-ingest can't masquerade as a successful provision. + String bulkBody = new String(bulkResponse.getEntity().getContent().readAllBytes(), StandardCharsets.UTF_8); + if (bulkBody.contains("\"errors\":true")) { + throw new IOException("bulk ingest reported item errors for " + dataset.indexName + ": " + bulkBody); + } + + Request flush = new Request("POST", "/" + dataset.indexName + "/_flush"); + flush.addParameter("force", "true"); + client.performRequest(flush); + + // Wait for the primary to be active before the first query. YELLOW (not GREEN): on a single-node + // dev server any replica stays unassigned forever, which would time out a green wait. + Request health = new Request("GET", "/_cluster/health/" + dataset.indexName); + health.addParameter("wait_for_status", "yellow"); + health.addParameter("wait_for_no_initializing_shards", "true"); + health.addParameter("timeout", "60s"); + client.performRequest(health); + + logger.info("Dataset [{}] provisioned into parquet index [{}]", dataset.name, dataset.indexName); + } + + /** + * Splice the parquet/composite data-format settings into the mapping body's settings block, + * anchored on the literal {@code "number_of_shards"} token. {@code secondary_data_formats} is the + * single string {@code "lucene"} so text-search predicates keep a Lucene backend. + */ + private static String injectParquetSettings(String mappingBody) { + return mappingBody.replace( + "\"number_of_shards\"", + "\"index.pluggable.dataformat.enabled\": true, " + + "\"index.pluggable.dataformat\": \"composite\", " + + "\"index.composite.primary_data_format\": \"parquet\", " + + "\"index.composite.secondary_data_formats\": \"lucene\", " + + "\"number_of_shards\"" + ); + } + + /** Load a classpath resource as a UTF-8 string (trailing newline preserved for ndjson). */ + public static String loadResource(String path) throws IOException { + try (InputStream is = DatasetProvisioner.class.getClassLoader().getResourceAsStream(path)) { + assertNotNull("Resource not found: " + path, is); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + String content = reader.lines().collect(Collectors.joining("\n")); + return content.isEmpty() ? content : content + "\n"; + } + } + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DatasetQueryRunner.java b/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DatasetQueryRunner.java new file mode 100644 index 0000000000000..81120b1376d9f --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DatasetQueryRunner.java @@ -0,0 +1,133 @@ +/* + * 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.types; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.client.RestClient; +import org.opensearch.common.io.PathUtils; + +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * Discovers DSL query files from a dataset's resource dir and runs them against a live cluster. + * For a dataset at {@code resources/datasets/{name}/}, queries are auto-discovered from + * {@code {language}/q{N}.{ext}} and executed via the supplied {@link QueryExecutor}. Failures are + * collected (not fail-fast) so every query is attempted. + * + *

Local copy tailored to this standalone module (no dependency on analytics-engine-rest). + */ +public final class DatasetQueryRunner { + + private static final Logger logger = LogManager.getLogger(DatasetQueryRunner.class); + private static final Pattern QUERY_FILE_PATTERN = Pattern.compile("q(\\d+)\\.\\w+"); + + /** Executes a single query against a live cluster and returns the response body as a Map. */ + @FunctionalInterface + public interface QueryExecutor { + Map execute(RestClient client, Dataset dataset, String queryBody) throws IOException; + } + + private DatasetQueryRunner() {} + + /** + * Discover all query numbers for the given dataset/language — sorted list of N such that + * {@code {language}/q{N}.{ext}} exists. + */ + public static List discoverQueryNumbers(Dataset dataset, String language) throws IOException { + String resourceDir = "datasets/" + dataset.name + "/" + language; + URL url = DatasetQueryRunner.class.getClassLoader().getResource(resourceDir); + if (url == null) { + return Collections.emptyList(); + } + List numbers = new ArrayList<>(); + FileSystem fs = null; + try { + URI uri = url.toURI(); + Path path; + if ("jar".equals(uri.getScheme())) { + fs = FileSystems.newFileSystem(uri, Collections.emptyMap()); + path = fs.getPath(resourceDir); + } else { + path = PathUtils.get(uri); + } + try (Stream stream = Files.list(path)) { + stream.forEach(p -> { + Matcher m = QUERY_FILE_PATTERN.matcher(p.getFileName().toString()); + if (m.matches()) { + numbers.add(Integer.parseInt(m.group(1))); + } + }); + } + } catch (Exception e) { + throw new IOException("Failed to discover queries for dataset [" + dataset.name + "] language [" + language + "]", e); + } finally { + if (fs != null) { + fs.close(); + } + } + Collections.sort(numbers); + return numbers; + } + + /** + * Run queries against the cluster using the supplied executor. If {@code queryNumbers} is null, + * auto-discovers all queries. Collects failures and returns them (does not fail-fast). + */ + public static List runQueries( + RestClient client, + Dataset dataset, + String language, + String extension, + List queryNumbers, + QueryExecutor executor + ) { + List queriesToRun = queryNumbers; + if (queriesToRun == null) { + try { + queriesToRun = discoverQueryNumbers(dataset, language); + } catch (IOException e) { + return Collections.singletonList("Failed to discover queries: " + e.getMessage()); + } + if (queriesToRun.isEmpty()) { + logger.warn("No queries discovered for dataset [{}] language [{}]", dataset.name, language); + return Collections.emptyList(); + } + } + + List failures = new ArrayList<>(); + for (int queryNum : queriesToRun) { + String queryId = language.toUpperCase(Locale.ROOT) + " Q" + queryNum; + try { + String queryBody = DatasetProvisioner.loadResource(dataset.queryResourcePath(language, extension, queryNum)); + Map response = executor.execute(client, dataset, queryBody); + if (response == null || response.isEmpty()) { + failures.add(queryId + ": empty response"); + } + } catch (Exception e) { + failures.add(queryId + " failed: " + e.getMessage()); + } + } + return failures; + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DslQueryTypeCatalog.java b/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DslQueryTypeCatalog.java new file mode 100644 index 0000000000000..31c500449eb07 --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DslQueryTypeCatalog.java @@ -0,0 +1,70 @@ +/* + * 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.types; + +import java.util.List; + +/** + * Catalog of DSL query types, one entry per {@code resources/datasets//} folder. + * + *

Each entry names a query-type folder (its {@link Dataset}: {@code mapping.json} + {@code bulk.json} + * + {@code dsl/q{N}.json}). {@link DslQueryTypesIT} turns every {@code (entry, queryNumber)} pair into + * an independent parameterized test that provisions the type's parquet index, runs the query, + * and validates the response against a golden file ({@code dsl/expected/q{N}.json}). + * + *

The suite's purpose is to map what the parquet/composite engine can and cannot serve. Every type + * is treated as expected-to-work: a type is green only when its live response matches its golden. + * Types the engine cannot handle today — geo/nested field mappings (rejected at index creation), + * multi-valued keyword arrays (rejected at ingest), custom {@code _id}s (rejected by + * {@code append_only}) — therefore surface as red, which is the finding we want to expose, not + * hide. There is no per-type "expected outcome" guesswork: the golden is the single source of truth. + */ +public final class DslQueryTypeCatalog { + + private DslQueryTypeCatalog() {} + + /** One query-type entry: its dataset folder/index and query family. */ + public static final class Entry { + /** Query-type key == folder name under {@code resources/datasets/}. */ + public final String type; + /** Query family (term-level, full-text, compound, scoring, span, relational, geo, specialized). */ + public final String family; + /** Dataset descriptor: folder name == index name == {@link #type}. */ + public final Dataset dataset; + + Entry(String type, String family) { + this.type = type; + this.family = family; + this.dataset = new Dataset(type, type); + } + } + + private static Entry e(String type, String family) { + return new Entry(type, family); + } + + /** All catalogued query types, aligned 1:1 with the {@code resources/datasets//} folders. */ + public static List all() { + return List.of( + // ── term-level ── + e("term", "term-level"), + + // ── compound ── + e("bool", "compound"), + + // ── single-valued-tags variants (probe parquet's multi-value keyword limitation) ── + // Same mapping + same query as the parent type, but with `tags` reduced from a multi-valued + // array to a bare scalar. The parent types fail on parquet at ingest — "Cannot accept multiple + // values for field [tags] of type [keyword]" — so these variants isolate that as the sole + // cause: with scalar tags the docs ingest and the parent's query is exercised normally. + e("term_scalar", "term-level"), + e("bool_scalar", "compound") + ); + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DslQueryTypesIT.java b/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DslQueryTypesIT.java new file mode 100644 index 0000000000000..322fd3aec47c8 --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DslQueryTypesIT.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.types; + +import com.carrotsearch.randomizedtesting.annotations.Name; +import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.client.Request; +import org.opensearch.client.Response; +import org.opensearch.test.rest.OpenSearchRestTestCase; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Per-query-type DSL integration test. One {@code resources/datasets//} folder per query type + * ({@code mapping.json} + {@code bulk.json} + {@code dsl/q{N}.json}); each is provisioned into a + * parquet/composite index via {@link DatasetProvisioner} and queried over HTTP against the live + * sandbox server. The test JVM loads no plugins — it only sends {@code _search} requests. + * + *

Every {@code (query-type, queryNumber)} pair from {@link DslQueryTypeCatalog} is an + * independent parameterized test (one instance per pair via {@link ParametersFactory}). A + * failure in one pair — a provisioning rejection, a non-200, or a golden mismatch — fails only that + * one test; every other pair still runs. This is deliberate: the suite maps what the parquet engine + * can and cannot serve, so unsupported shapes (geo/nested, multi-valued keyword arrays, custom + * {@code _id}s) show up as isolated reds rather than aborting the sweep. + * + *

Each test provisions the dataset, runs its query, asserts HTTP 200, and validates the response + * against the expected answer at {@code dsl/expected/q{N}.json} via {@link DslResponseValidator}. + * Expected answers are generated out-of-band on a vanilla OpenSearch index and committed; this suite + * only validates against them. + * + *

+ *   ./gradlew :sandbox:qa:dsl-query-types:restTest \
+ *     --tests "org.opensearch.dsl.types.DslQueryTypesIT" -PrestCluster=localhost:9200
+ * 
+ */ +public class DslQueryTypesIT extends OpenSearchRestTestCase { + + private static final Logger logger = LogManager.getLogger(DslQueryTypesIT.class); + + private final DslQueryTypeCatalog.Entry entry; + private final int queryNumber; + + public DslQueryTypesIT(@Name("param") Param param) { + this.entry = param.entry; + this.queryNumber = param.queryNumber; + } + + @Override + protected boolean preserveClusterUponCompletion() { + return true; + } + + @Override + protected boolean preserveIndicesUponCompletion() { + return true; + } + + /** One parameterized test = one (query-type, queryNumber) pair. */ + public static final class Param { + final DslQueryTypeCatalog.Entry entry; + final int queryNumber; + + Param(DslQueryTypeCatalog.Entry entry, int queryNumber) { + this.entry = entry; + this.queryNumber = queryNumber; + } + + @Override + public String toString() { + // Drives the per-test display name, e.g. "term/q1". + return entry.type + "/q" + queryNumber; + } + } + + @ParametersFactory(shuffle = false) + public static Iterable parameters() throws Exception { + List params = new ArrayList<>(); + for (DslQueryTypeCatalog.Entry entry : DslQueryTypeCatalog.all()) { + List queryNumbers = DatasetQueryRunner.discoverQueryNumbers(entry.dataset, "dsl"); + if (queryNumbers.isEmpty()) { + logger.warn("No dsl/q*.json queries discovered for dataset [{}] — skipping", entry.type); + continue; + } + for (int queryNumber : queryNumbers) { + params.add(new Object[] { new Param(entry, queryNumber) }); + } + } + return params; + } + + /** + * Provision this pair's dataset on the parquet backend, run its query, and validate the response + * against the committed golden. Any failure — provisioning rejection, non-200, or golden mismatch — + * fails just this test. + * + *

Goldens are the TRUE (vanilla Lucene) answer, generated out-of-band and committed; parquet is + * validated against them, so its deviations — null text {@code _source}, multi-valued keyword / + * custom-id / geo / nested rejections — surface as red. See {@link DslResponseValidator}. + */ + public void testQueryType() throws Exception { + // A provisioning rejection (geo/nested mapping, multi-valued keyword, custom _id) is a real, + // expected finding for unsupported shapes on parquet: it fails this one test, not the sweep. + DatasetProvisioner.provision(client(), entry.dataset); + + String queryBody = DatasetProvisioner.loadResource(entry.dataset.queryResourcePath("dsl", "json", queryNumber)); + Request request = new Request("POST", "/" + entry.dataset.indexName + "/_search"); + request.setJsonEntity(queryBody); + Response response = client().performRequest(request); + assertEquals( + "DSL " + entry.type + " Q" + queryNumber + ": expected HTTP 200", + 200, + response.getStatusLine().getStatusCode() + ); + Map actual = entityAsMap(response); + + String failure = DslResponseValidator.validate(entry.dataset, queryNumber, actual); + assertNull(failure, failure); + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DslResponseValidator.java b/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DslResponseValidator.java new file mode 100644 index 0000000000000..7b4e13abfa867 --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DslResponseValidator.java @@ -0,0 +1,264 @@ +/* + * 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.types; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; + +/** + * Validates a parquet DSL {@code _search} response against a committed golden file. + * + *

Golden files live at {@code datasets//dsl/expected/q.json} and capture the meaningful + * parts of a search response: + *

+ *   {
+ *     "hits": {
+ *       "total": 3,
+ *       "sources": [ { ... _source ... }, ... ]   // order-independent
+ *     }
+ *   }
+ * 
+ * {@code total} is compared against {@code hits.total.value}; {@code sources} is compared against each + * hit's {@code _source} as an unordered multiset with numeric tolerance. A {@code total} of 0 with an + * empty (or absent) {@code sources} is a perfectly valid golden — e.g. an aggregation-only query or a + * query that legitimately matches nothing. + * + *

The expected answer is the TRUE answer, not parquet's answer. Expected answers are generated + * out-of-band by running each dataset on a vanilla OpenSearch index (default Lucene backend, no parquet + * settings) — the reference implementation — and committed. This suite only ever validates the + * parquet response against the committed expected answer, so any place parquet deviates (null text + * {@code _source}, multi-valued keyword / custom-id / geo / nested failures) correctly shows red. This + * mirrors the PPL/SQL suite in {@code analytics-engine-rest}, whose expected files are known-correct + * answers, never the engine's own output. + */ +public final class DslResponseValidator { + + private static final double NUMERIC_TOLERANCE = 1e-9; + + private DslResponseValidator() {} + + /** + * Validate {@code actual} against the golden for {@code dataset}/q{@code queryNumber}. + * + * @return {@code null} when validation passes; otherwise a human-readable failure message + */ + public static String validate(Dataset dataset, int queryNumber, Map actual) { + String label = "DSL " + dataset.name + " Q" + queryNumber; + String goldenPath = dataset.expectedResponseResourcePath("dsl", queryNumber); + + if (DslResponseValidator.class.getClassLoader().getResource(goldenPath) == null) { + return label + ": no golden file at " + goldenPath; + } + + Map golden; + try { + golden = parseJson(DatasetProvisioner.loadResource(goldenPath)); + } catch (Exception e) { + return label + ": failed to load/parse golden " + goldenPath + ": " + e.getMessage(); + } + + return compare(golden, actual, label); + } + + // ── comparison ─────────────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private static String compare(Map golden, Map actual, String label) { + Map goldenHits = (Map) golden.get("hits"); + if (goldenHits == null) { + return label + ": golden has no \"hits\" block"; + } + + long expectedTotal = asLong(goldenHits.get("total")); + long actualTotal = extractTotal(actual); + if (expectedTotal != actualTotal) { + return String.format(Locale.ROOT, "%s: total mismatch - expected %d, got %d", label, expectedTotal, actualTotal); + } + + List> expectedSources = asSourceList(goldenHits.get("sources")); + List> actualSources = extractSources(actual); + + if (expectedSources.size() != actualSources.size()) { + return String.format( + Locale.ROOT, + "%s: hit count mismatch - expected %d sources, got %d", + label, + expectedSources.size(), + actualSources.size() + ); + } + + // Order-independent: sort both by canonical string form, then compare element-wise. + List> exp = new ArrayList<>(expectedSources); + List> act = new ArrayList<>(actualSources); + Comparator> byCanonical = Comparator.comparing(DslResponseValidator::canonical); + exp.sort(byCanonical); + act.sort(byCanonical); + + for (int i = 0; i < exp.size(); i++) { + String diff = compareSource(exp.get(i), act.get(i), label + " source " + i); + if (diff != null) { + return diff; + } + } + return null; + } + + private static String compareSource(Map expected, Map actual, String label) { + if (expected.size() != actual.size()) { + return String.format( + Locale.ROOT, + "%s: field count mismatch - expected %s, got %s", + label, + new TreeMap<>(expected).keySet(), + new TreeMap<>(actual).keySet() + ); + } + for (Map.Entry e : expected.entrySet()) { + if (!actual.containsKey(e.getKey())) { + return String.format(Locale.ROOT, "%s: missing field [%s]", label, e.getKey()); + } + if (!valuesEqual(e.getValue(), actual.get(e.getKey()))) { + return String.format( + Locale.ROOT, + "%s field [%s]: value mismatch - expected %s, got %s", + label, + e.getKey(), + e.getValue(), + actual.get(e.getKey()) + ); + } + } + return null; + } + + @SuppressWarnings("unchecked") + private static boolean valuesEqual(Object expected, Object actual) { + if (expected instanceof Number && actual instanceof Number) { + return Math.abs(((Number) expected).doubleValue() - ((Number) actual).doubleValue()) < NUMERIC_TOLERANCE; + } + if (expected instanceof List && actual instanceof List) { + List e = new ArrayList<>((List) expected); + List a = new ArrayList<>((List) actual); + if (e.size() != a.size()) { + return false; + } + Comparator byStr = Comparator.comparing(o -> o == null ? "" : o.toString()); + e.sort(byStr); + a.sort(byStr); + for (int i = 0; i < e.size(); i++) { + if (!valuesEqual(e.get(i), a.get(i))) { + return false; + } + } + return true; + } + if (expected instanceof Map && actual instanceof Map) { + return compareSource((Map) expected, (Map) actual, "nested") == null; + } + return Objects.equals(expected, actual); + } + + // ── extraction from a live response ──────────────────────────────────────── + + @SuppressWarnings("unchecked") + private static long extractTotal(Map response) { + Map hits = (Map) response.get("hits"); + if (hits == null) { + return 0; + } + Object total = hits.get("total"); + if (total instanceof Map) { + return asLong(((Map) total).get("value")); + } + // Older/typed responses may carry a bare number. + return asLong(total); + } + + @SuppressWarnings("unchecked") + private static List> extractSources(Map response) { + List> out = new ArrayList<>(); + Map hits = (Map) response.get("hits"); + if (hits == null) { + return out; + } + List> hitList = (List>) hits.get("hits"); + if (hitList == null) { + return out; + } + for (Map hit : hitList) { + Object src = hit.get("_source"); + out.add(src instanceof Map ? (Map) src : Map.of()); + } + return out; + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private static Map parseJson(String json) throws IOException { + return org.opensearch.common.xcontent.XContentHelper.convertToMap( + org.opensearch.common.xcontent.XContentType.JSON.xContent(), + json, + false + ); + } + + private static long asLong(Object o) { + return o instanceof Number ? ((Number) o).longValue() : 0L; + } + + @SuppressWarnings("unchecked") + private static List> asSourceList(Object o) { + List> out = new ArrayList<>(); + if (o instanceof List) { + for (Object e : (List) o) { + if (e instanceof Map) { + out.add((Map) e); + } + } + } + return out; + } + + /** Canonical string form of a source map: keys sorted recursively, for order-independent compare. */ + @SuppressWarnings("unchecked") + private static String canonical(Object o) { + if (o instanceof Map) { + TreeMap sorted = new TreeMap<>((Map) o); + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + for (Map.Entry e : sorted.entrySet()) { + if (!first) { + sb.append(","); + } + first = false; + sb.append('"').append(e.getKey()).append("\":").append(canonical(e.getValue())); + } + return sb.append("}").toString(); + } + if (o instanceof List) { + List parts = new ArrayList<>(); + for (Object e : (List) o) { + parts.add(canonical(e)); + } + parts.sort(Comparator.naturalOrder()); + return "[" + String.join(",", parts) + "]"; + } + if (o instanceof String) { + return '"' + (String) o + '"'; + } + return String.valueOf(o); + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DslTermQueryIT.java b/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DslTermQueryIT.java new file mode 100644 index 0000000000000..44efa7ec24e81 --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/java/org/opensearch/dsl/types/DslTermQueryIT.java @@ -0,0 +1,174 @@ +/* + * 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.types; + +import org.opensearch.client.Request; +import org.opensearch.client.Response; +import org.opensearch.test.rest.OpenSearchRestTestCase; +import org.junit.Before; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Focused integration test for the DSL {@code term} query against the live sandbox server, running + * over a parquet/composite index (primary=parquet, secondary=lucene) via the standard search path. + * + *

Unlike {@link DslQueryTypesIT} — which sweeps every query type and validates each response + * against a golden file — this test mirrors the depth of the {@code server/.../search/query/*IT} + * tests: it asserts real result correctness (hit count, {@code _source} values, matched + * documents) for a single query type, in code rather than from a golden. + * + *

Dataset: {@code datasets/people/} (5 docs — alice/bob/carol/dave/eve; 3 in seattle, 2 in + * portland), provisioned into a dual-format index (primary=parquet, secondary=lucene). + * + *

Assertions covered for the {@code term} query: exact hit count, exact {@code _source} field + * values, and result ordering. Not asserted: {@code _score} / relevance (the analytics engine + * computes no relevance score — {@code max_score}/{@code _score} are null on this backend). + * + *

+ *   ./gradlew :sandbox:qa:dsl-query-types:restTest \
+ *     --tests "org.opensearch.dsl.types.DslTermQueryIT" -PrestCluster=localhost:9200
+ * 
+ */ +public class DslTermQueryIT extends OpenSearchRestTestCase { + + private static final Dataset PEOPLE = new Dataset("people", "dsl_people"); + private static boolean provisioned = false; + + @Override + protected boolean preserveClusterUponCompletion() { + return true; + } + + @Override + protected boolean preserveIndicesUponCompletion() { + return true; + } + + @Before + public void provisionOnce() throws IOException { + if (provisioned == false) { + DatasetProvisioner.provision(client(), PEOPLE); + provisioned = true; + } + } + + /** term on a keyword field matching 3 docs: assert exact count and the matching names. */ + public void testTermMatchesExpectedDocuments() throws IOException { + Map response = search("{\"query\":{\"term\":{\"city\":\"seattle\"}}}"); + + assertEquals("hit count for term(city=seattle)", 3, totalHits(response)); + assertEquals("names matching city=seattle", List.of("alice", "carol", "eve"), sortedNames(response)); + } + + /** term matching a different value set: assert count and names. */ + public void testTermMatchesOtherValue() throws IOException { + Map response = search("{\"query\":{\"term\":{\"city\":\"portland\"}}}"); + + assertEquals("hit count for term(city=portland)", 2, totalHits(response)); + assertEquals("names matching city=portland", List.of("bob", "dave"), sortedNames(response)); + } + + /** term matching exactly one doc: assert the full _source values of that doc. */ + public void testTermSingleDocumentSourceValues() throws IOException { + Map response = search("{\"query\":{\"term\":{\"name\":\"alice\"}}}"); + + assertEquals("hit count for term(name=alice)", 1, totalHits(response)); + Map src = firstSource(response); + assertEquals("alice", src.get("name")); + assertEquals("seattle", src.get("city")); + assertEquals(30, ((Number) src.get("age")).intValue()); + assertEquals(95.5, ((Number) src.get("score")).doubleValue(), 1e-9); + assertEquals("This is seattle city", src.get("description")); + } + + /** term matching no docs: assert an empty result (0 hits), not an error. */ + public void testTermMatchesNothing() throws IOException { + Map response = search("{\"query\":{\"term\":{\"city\":\"denver\"}}}"); + + assertEquals("hit count for term(city=denver)", 0, totalHits(response)); + assertTrue("hits should be empty for a non-matching term", hits(response).isEmpty()); + } + + /** + * REST equivalent of {@code SimpleSearchIT.testSearchRandomPreference}: run {@code match_all} + * repeatedly with randomized {@code preference} values and assert the hit count is stably the + * full document count (5) every time. Unlike the transport-client original — which uses + * {@code indexRandom(...).setId(...)} — the parquet/composite index sets + * {@code index.append_only.enabled} (custom ids rejected), so the dataset is bulk-ingested with + * auto-generated ids via {@link DatasetProvisioner}. + */ + public void testMatchAllStableCountWithRandomPreference() throws IOException { + int iters = scaledRandomIntBetween(10, 20); + for (int i = 0; i < iters; i++) { + String preference = randomAlphaOfLengthBetween(1, 4); + // preference must not start with '_' (reserved for known types e.g. _shards, _primary) + while (preference.startsWith("_")) { + preference = randomAlphaOfLengthBetween(1, 4); + } + Map response = search("{\"query\":{\"match_all\":{}}}", preference); + assertEquals("match_all count must be stable across preferences", 5, totalHits(response)); + } + } + + // ── helpers ────────────────────────────────────────────────────────────── + + /** Run a search with an optional {@code preference} query param. */ + private Map search(String body, String preference) throws IOException { + Request request = new Request("POST", "/" + PEOPLE.indexName + "/_search"); + request.setJsonEntity(body); + if (preference != null) { + request.addParameter("preference", preference); + } + Response response = client().performRequest(request); + assertEquals("expected HTTP 200 for: " + body, 200, response.getStatusLine().getStatusCode()); + return entityAsMap(response); + } + + private Map search(String body) throws IOException { + Request request = new Request("POST", "/" + PEOPLE.indexName + "/_search"); + request.setJsonEntity(body); + Response response = client().performRequest(request); + assertEquals("expected HTTP 200 for: " + body, 200, response.getStatusLine().getStatusCode()); + return entityAsMap(response); + } + + @SuppressWarnings("unchecked") + private static int totalHits(Map response) { + Map hits = (Map) response.get("hits"); + Map total = (Map) hits.get("total"); + return ((Number) total.get("value")).intValue(); + } + + @SuppressWarnings("unchecked") + private static List> hits(Map response) { + Map hits = (Map) response.get("hits"); + return (List>) hits.get("hits"); + } + + @SuppressWarnings("unchecked") + private static Map firstSource(Map response) { + return (Map) hits(response).get(0).get("_source"); + } + + /** Names from every hit's _source, sorted — order-independent set comparison for filter tests. */ + @SuppressWarnings("unchecked") + private static List sortedNames(Map response) { + List names = new ArrayList<>(); + for (Map hit : hits(response)) { + Map src = (Map) hit.get("_source"); + names.add((String) src.get("name")); + } + return names.stream().sorted().collect(Collectors.toList()); + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool/bulk.json b/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool/bulk.json new file mode 100644 index 0000000000000..2d21744aaac66 --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool/bulk.json @@ -0,0 +1,8 @@ +{"index": {}} +{"name": "laptop", "brand": "brandX", "tags": ["laptop", "phone"], "price": 1200, "rating": 4.5, "stock": 10} +{"index": {}} +{"name": "phone", "brand": "brandY", "tags": ["electronics", "mobile"], "price": 800, "rating": 4.0, "stock": 25} +{"index": {}} +{"name": "tablet", "brand": "brandX", "tags": ["electronics", "mobile"], "price": 600, "rating": 3.5, "stock": 0} +{"index": {}} +{"name": "monitor", "brand": "brandZ", "tags": ["electronics", "display"], "price": 300, "rating": 4.2, "stock": 5} diff --git a/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool/dsl/expected/q1.json b/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool/dsl/expected/q1.json new file mode 100644 index 0000000000000..ae5541cb3dacd --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool/dsl/expected/q1.json @@ -0,0 +1,8 @@ +{ + "hits": { + "total": 1, + "sources": [ + { "name": "laptop", "brand": "brandX", "tags": ["laptop", "phone"], "price": 1200, "rating": 4.5, "stock": 10 } + ] + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool/dsl/q1.json b/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool/dsl/q1.json new file mode 100644 index 0000000000000..c61979f0c68bb --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool/dsl/q1.json @@ -0,0 +1,20 @@ +{ + "query": { + "bool": { + "filter": [ + { + "term": { + "brand": "brandX" + } + } + ], + "must": [ + { + "term": { + "tags": "laptop" + } + } + ] + } + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool/mapping.json b/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool/mapping.json new file mode 100644 index 0000000000000..0a6bd4d312ef6 --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool/mapping.json @@ -0,0 +1,28 @@ +{ + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0 + }, + "mappings": { + "properties": { + "name": { + "type": "keyword" + }, + "brand": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "price": { + "type": "double" + }, + "rating": { + "type": "double" + }, + "stock": { + "type": "integer" + } + } + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool_scalar/bulk.json b/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool_scalar/bulk.json new file mode 100644 index 0000000000000..91cf33f01670b --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool_scalar/bulk.json @@ -0,0 +1,8 @@ +{"index": {}} +{"name":"laptop","brand":"brandX","tags":"laptop","price":1200,"rating":4.5,"stock":10} +{"index": {}} +{"name":"phone","brand":"brandY","tags":"electronics","price":800,"rating":4.0,"stock":25} +{"index": {}} +{"name":"tablet","brand":"brandX","tags":"electronics","price":600,"rating":3.5,"stock":0} +{"index": {}} +{"name":"monitor","brand":"brandZ","tags":"electronics","price":300,"rating":4.2,"stock":5} diff --git a/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool_scalar/dsl/expected/q1.json b/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool_scalar/dsl/expected/q1.json new file mode 100644 index 0000000000000..fc1cf6cf6c366 --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool_scalar/dsl/expected/q1.json @@ -0,0 +1,8 @@ +{ + "hits": { + "total": 1, + "sources": [ + { "name": "laptop", "brand": "brandX", "tags": "laptop", "price": 1200, "rating": 4.5, "stock": 10 } + ] + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool_scalar/dsl/q1.json b/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool_scalar/dsl/q1.json new file mode 100644 index 0000000000000..c61979f0c68bb --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool_scalar/dsl/q1.json @@ -0,0 +1,20 @@ +{ + "query": { + "bool": { + "filter": [ + { + "term": { + "brand": "brandX" + } + } + ], + "must": [ + { + "term": { + "tags": "laptop" + } + } + ] + } + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool_scalar/mapping.json b/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool_scalar/mapping.json new file mode 100644 index 0000000000000..0a6bd4d312ef6 --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/resources/datasets/bool_scalar/mapping.json @@ -0,0 +1,28 @@ +{ + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0 + }, + "mappings": { + "properties": { + "name": { + "type": "keyword" + }, + "brand": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "price": { + "type": "double" + }, + "rating": { + "type": "double" + }, + "stock": { + "type": "integer" + } + } + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/resources/datasets/people/bulk.json b/sandbox/qa/dsl-query-types/src/test/resources/datasets/people/bulk.json new file mode 100644 index 0000000000000..39bf1740cfce4 --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/resources/datasets/people/bulk.json @@ -0,0 +1,10 @@ +{"index": {}} +{"name": "alice", "age": 30, "score": 95.5, "city": "seattle", "description": "This is seattle city"} +{"index": {}} +{"name": "bob", "age": 25, "score": 88.0, "city": "portland", "description": "This is portland"} +{"index": {}} +{"name": "carol", "age": 35, "score": 92.3, "city": "seattle", "description": "This is seattle city"} +{"index": {}} +{"name": "dave", "age": 28, "score": 76.8, "city": "portland", "description": "This is portland"} +{"index": {}} +{"name": "eve", "age": 32, "score": 91.0, "city": "seattle", "description": "This is seattle city"} diff --git a/sandbox/qa/dsl-query-types/src/test/resources/datasets/people/mapping.json b/sandbox/qa/dsl-query-types/src/test/resources/datasets/people/mapping.json new file mode 100644 index 0000000000000..35e9858ba999d --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/resources/datasets/people/mapping.json @@ -0,0 +1,15 @@ +{ + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0 + }, + "mappings": { + "properties": { + "name": { "type": "keyword" }, + "age": { "type": "integer" }, + "score": { "type": "double" }, + "city": { "type": "keyword" }, + "description": { "type": "text" } + } + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/resources/datasets/term/bulk.json b/sandbox/qa/dsl-query-types/src/test/resources/datasets/term/bulk.json new file mode 100644 index 0000000000000..2d21744aaac66 --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/resources/datasets/term/bulk.json @@ -0,0 +1,8 @@ +{"index": {}} +{"name": "laptop", "brand": "brandX", "tags": ["laptop", "phone"], "price": 1200, "rating": 4.5, "stock": 10} +{"index": {}} +{"name": "phone", "brand": "brandY", "tags": ["electronics", "mobile"], "price": 800, "rating": 4.0, "stock": 25} +{"index": {}} +{"name": "tablet", "brand": "brandX", "tags": ["electronics", "mobile"], "price": 600, "rating": 3.5, "stock": 0} +{"index": {}} +{"name": "monitor", "brand": "brandZ", "tags": ["electronics", "display"], "price": 300, "rating": 4.2, "stock": 5} diff --git a/sandbox/qa/dsl-query-types/src/test/resources/datasets/term/dsl/expected/q1.json b/sandbox/qa/dsl-query-types/src/test/resources/datasets/term/dsl/expected/q1.json new file mode 100644 index 0000000000000..ae5541cb3dacd --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/resources/datasets/term/dsl/expected/q1.json @@ -0,0 +1,8 @@ +{ + "hits": { + "total": 1, + "sources": [ + { "name": "laptop", "brand": "brandX", "tags": ["laptop", "phone"], "price": 1200, "rating": 4.5, "stock": 10 } + ] + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/resources/datasets/term/dsl/q1.json b/sandbox/qa/dsl-query-types/src/test/resources/datasets/term/dsl/q1.json new file mode 100644 index 0000000000000..09e38ef308f07 --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/resources/datasets/term/dsl/q1.json @@ -0,0 +1,7 @@ +{ + "query": { + "term": { + "name": "laptop" + } + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/resources/datasets/term/mapping.json b/sandbox/qa/dsl-query-types/src/test/resources/datasets/term/mapping.json new file mode 100644 index 0000000000000..0a6bd4d312ef6 --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/resources/datasets/term/mapping.json @@ -0,0 +1,28 @@ +{ + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0 + }, + "mappings": { + "properties": { + "name": { + "type": "keyword" + }, + "brand": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "price": { + "type": "double" + }, + "rating": { + "type": "double" + }, + "stock": { + "type": "integer" + } + } + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/resources/datasets/term_scalar/bulk.json b/sandbox/qa/dsl-query-types/src/test/resources/datasets/term_scalar/bulk.json new file mode 100644 index 0000000000000..91cf33f01670b --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/resources/datasets/term_scalar/bulk.json @@ -0,0 +1,8 @@ +{"index": {}} +{"name":"laptop","brand":"brandX","tags":"laptop","price":1200,"rating":4.5,"stock":10} +{"index": {}} +{"name":"phone","brand":"brandY","tags":"electronics","price":800,"rating":4.0,"stock":25} +{"index": {}} +{"name":"tablet","brand":"brandX","tags":"electronics","price":600,"rating":3.5,"stock":0} +{"index": {}} +{"name":"monitor","brand":"brandZ","tags":"electronics","price":300,"rating":4.2,"stock":5} diff --git a/sandbox/qa/dsl-query-types/src/test/resources/datasets/term_scalar/dsl/expected/q1.json b/sandbox/qa/dsl-query-types/src/test/resources/datasets/term_scalar/dsl/expected/q1.json new file mode 100644 index 0000000000000..fc1cf6cf6c366 --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/resources/datasets/term_scalar/dsl/expected/q1.json @@ -0,0 +1,8 @@ +{ + "hits": { + "total": 1, + "sources": [ + { "name": "laptop", "brand": "brandX", "tags": "laptop", "price": 1200, "rating": 4.5, "stock": 10 } + ] + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/resources/datasets/term_scalar/dsl/q1.json b/sandbox/qa/dsl-query-types/src/test/resources/datasets/term_scalar/dsl/q1.json new file mode 100644 index 0000000000000..09e38ef308f07 --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/resources/datasets/term_scalar/dsl/q1.json @@ -0,0 +1,7 @@ +{ + "query": { + "term": { + "name": "laptop" + } + } +} diff --git a/sandbox/qa/dsl-query-types/src/test/resources/datasets/term_scalar/mapping.json b/sandbox/qa/dsl-query-types/src/test/resources/datasets/term_scalar/mapping.json new file mode 100644 index 0000000000000..0a6bd4d312ef6 --- /dev/null +++ b/sandbox/qa/dsl-query-types/src/test/resources/datasets/term_scalar/mapping.json @@ -0,0 +1,28 @@ +{ + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0 + }, + "mappings": { + "properties": { + "name": { + "type": "keyword" + }, + "brand": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "price": { + "type": "double" + }, + "rating": { + "type": "double" + }, + "stock": { + "type": "integer" + } + } + } +}