From db7467b45e5c5b8396d1fcac772d8b1d4359399d Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Wed, 7 Oct 2026 10:30:15 -0400 Subject: [PATCH 1/6] SOLR-16640: Admin UI's SQL screen shows an error instead of crashing doQuery() assumed every response was a SQL result-set and crashed with an uncaught TypeError ("Cannot read properties of undefined (reading 'docs')") whenever it wasn't - e.g. when the sql module/handler isn't installed, which returns a 404 JSON body with no "result-set" key. The UI was then left showing a blank grid with no explanation. Wrapped the response handling (shared between the success and error callbacks, since app.js's doNotIntercept interceptor quirk routes most failures through the success callback too - same root cause as SOLR-9759) to fall back to showing the raw message via the existing sqlError display instead of crashing. --- ...solr-16640-admin-ui-sql-error-handling.yml | 8 +++ .../solr/webapp/AdminUiSqlScreenTest.java | 16 +++++ .../web/js/angular/controllers/sqlquery.js | 71 ++++++++++++------- 3 files changed, 70 insertions(+), 25 deletions(-) create mode 100644 changelog/unreleased/solr-16640-admin-ui-sql-error-handling.yml diff --git a/changelog/unreleased/solr-16640-admin-ui-sql-error-handling.yml b/changelog/unreleased/solr-16640-admin-ui-sql-error-handling.yml new file mode 100644 index 000000000000..9a628d567e51 --- /dev/null +++ b/changelog/unreleased/solr-16640-admin-ui-sql-error-handling.yml @@ -0,0 +1,8 @@ +# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc +title: Admin UI's SQL screen no longer crashes with an uncaught JS error (and silently shows a blank result grid) when the response isn't a SQL result-set - e.g. when the sql module/handler isn't installed. It now shows the server's error message instead. +type: fixed +authors: + - name: Eric Pugh +links: + - name: SOLR-16640 + url: https://issues.apache.org/jira/browse/SOLR-16640 diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java index c49871834ceb..065cb178c1e0 100644 --- a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java @@ -57,4 +57,20 @@ public void testSqlQueryViaUi() { } assertNoSevereConsoleErrors(); } + + @Test + public void testFailedRequestShowsErrorInsteadOfCrashing() { + // a nonexistent collection makes the request fail with a response that has no "result-set" + // key - the same shape the server returns when the sql module/handler isn't installed + // (SOLR-16640). Before the fix, parsing this crashed with an uncaught TypeError and the + // screen just stayed blank with no explanation. + openPage("nonexistentcoll/sqlquery", By.id("sqlquery")); + WebElement stmt = waitFor(By.id("sqlexp")); + stmt.clear(); + stmt.sendKeys("SELECT id FROM " + COLLECTION + " LIMIT 10"); + click(By.xpath("//div[@id='sqlquery']//button[@type='submit']")); + waitForTextContains(By.id("sql-response"), "no handler, collection, or core"); + // the request is expected to fail (404) - that's the scenario under test + assertNoSevereConsoleErrors("404 (Not Found)"); + } } diff --git a/solr/webapp/web/js/angular/controllers/sqlquery.js b/solr/webapp/web/js/angular/controllers/sqlquery.js index 7aabc887d5e4..4f959bb3515c 100644 --- a/solr/webapp/web/js/angular/controllers/sqlquery.js +++ b/solr/webapp/web/js/angular/controllers/sqlquery.js @@ -52,36 +52,57 @@ solrAdminApp.controller('SQLQueryController', $scope.gridOptions.columnDefs = [] var url = Query.url(params); - Query.query(params, function(data) { - var jsonData = JSON.parse(data.toJSON().data); + // Handles both the success and error callbacks below: app.js's global interceptor + // (see failed()'s doNotIntercept branch) routes most failures for this request through + // the "success" callback too, so this must defend against a response that isn't a SQL + // result-set either way (e.g. the sql module/handler isn't installed - SOLR-16640). + var showResult = function(raw) { $scope.lang = "json"; $scope.url = url; $scope.sqlError = null; $scope.sqlData = []; - if(jsonData != undefined){ - var docs = jsonData['result-set'].docs - //get all docs - for (var i = 0; i < docs.length; i++) { - var doc = docs[i] - //get all the properties - if(doc.hasOwnProperty("EOF")){ - if(doc.hasOwnProperty("EXCEPTION")){ - $scope.sqlError = doc.EXCEPTION - } - } else { - $scope.gridOptions.data.push(doc); - } - } - } - //Build the columnFields from data - var fields = $scope.gridOptions.data[1]; - for (var property in fields) { - if (fields.hasOwnProperty(property)) { - $scope.gridOptions.columnDefs.push({"name":property, "type":{}}) - } - } - $scope.gridApi.core.notifyDataChange + + var jsonData; + try { + jsonData = JSON.parse(raw); + } catch (e) { + $scope.sqlError = raw; + return; + } + + var docs = jsonData && jsonData['result-set'] && jsonData['result-set'].docs; + if (!docs) { + $scope.sqlError = (jsonData && jsonData.message) || raw; + return; + } + + //get all docs + for (var i = 0; i < docs.length; i++) { + var doc = docs[i] + //get all the properties + if(doc.hasOwnProperty("EOF")){ + if(doc.hasOwnProperty("EXCEPTION")){ + $scope.sqlError = doc.EXCEPTION + } + } else { + $scope.gridOptions.data.push(doc); + } + } + //Build the columnFields from data + var fields = $scope.gridOptions.data[1]; + for (var property in fields) { + if (fields.hasOwnProperty(property)) { + $scope.gridOptions.columnDefs.push({"name":property, "type":{}}) + } + } + $scope.gridApi.core.notifyDataChange + }; + + Query.query(params, function(data) { + showResult(data.toJSON().data); + }, function(rejection) { + showResult((rejection.data && rejection.data.data) || ("HTTP " + rejection.status + " " + rejection.statusText)); }); }; } From 5eecfe0e3a0754884313ec377b6f1ee28d708592 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Wed, 7 Oct 2026 10:45:02 -0400 Subject: [PATCH 2/6] SOLR-16640: Detect the sql module missing and show a friendly message There's no v2 API to proactively check whether the sql module/handler is actually loadable (every core nominally registers /sql lazily regardless of whether the module jar is present, so only a real request reveals it's missing) - confirmed by checking for a modules- info or eager-plugin-load API, finding none. So instead: detect the specific ClassNotFoundException-for-SQLHandler shape in the error response and show "the sql module doesn't appear to be enabled" instead of the raw stack trace. Verified against a real build without the sql module (dev-slim) that this is the exact error shape produced. Also hoisted showResult() out of doQuery() so it's available as soon as the controller loads rather than only after the first query attempt - this also makes it directly testable (new testSqlModuleNotEnabledShowsFriendlyMessage calls it via the Angular scope with the captured error shape, since the webapp test classpath always has the sql module and can't reproduce the missing-module case through a real request). --- .../solr/webapp/AdminUiSqlScreenTest.java | 32 ++++++ .../web/js/angular/controllers/sqlquery.js | 106 +++++++++--------- 2 files changed, 88 insertions(+), 50 deletions(-) diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java index 065cb178c1e0..3ee818f3b11a 100644 --- a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java @@ -21,6 +21,7 @@ import org.junit.BeforeClass; import org.junit.Test; import org.openqa.selenium.By; +import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.WebElement; /** @@ -73,4 +74,35 @@ public void testFailedRequestShowsErrorInsteadOfCrashing() { // the request is expected to fail (404) - that's the scenario under test assertNoSevereConsoleErrors("404 (Not Found)"); } + + @Test + public void testSqlModuleNotEnabledShowsFriendlyMessage() { + // The real response when the sql module isn't on the classpath (reproduced against an + // actual build without the module): a 500 with this exact error envelope shape, since /sql + // is always nominally registered (lazily) for every core regardless of whether the module + // jar is present - only the first real request reveals the class is missing. Invoking + // showResult() directly with this captured shape, rather than needing a real sql-less + // build, since the webapp test classpath always has the module. + openPage(COLLECTION + "/sqlquery", By.id("sqlquery")); + waitFor(By.id("sqlexp")); + String classNotFoundResponse = + "{\"error\":{\"metadata\":{\"error-class\":\"org.apache.solr.common.SolrException\"," + + "\"root-error-class\":\"java.lang.ClassNotFoundException\"}," + + "\"errorClass\":\"org.apache.solr.common.SolrException\"," + + "\"msg\":\" Error loading class 'solr.SQLHandler'\",\"code\":500}}"; + String sqlError = + (String) + ((JavascriptExecutor) driver) + .executeScript( + "var scope = angular.element(document.getElementById('sqlquery')).scope();" + + "scope.showResult(arguments[0]);" + + "scope.$apply();" + + "return scope.sqlError;", + classNotFoundResponse); + assertTrue( + "should show a friendly message, got: " + sqlError, + sqlError != null && sqlError.toLowerCase().contains("sql module")); + assertTrue( + "should say how to fix it: " + sqlError, sqlError.toLowerCase().contains("enable")); + } } diff --git a/solr/webapp/web/js/angular/controllers/sqlquery.js b/solr/webapp/web/js/angular/controllers/sqlquery.js index 4f959bb3515c..425337ce9808 100644 --- a/solr/webapp/web/js/angular/controllers/sqlquery.js +++ b/solr/webapp/web/js/angular/controllers/sqlquery.js @@ -31,6 +31,59 @@ solrAdminApp.controller('SQLQueryController', } }; $scope.hostPortContext = $location.absUrl().substr(0,$location.absUrl().indexOf("#")); // For display only + + // Handles both the success and error callbacks below: app.js's global interceptor + // (see failed()'s doNotIntercept branch) routes most failures for this request through + // the "success" callback too, so this must defend against a response that isn't a SQL + // result-set either way (e.g. the sql module/handler isn't installed - SOLR-16640). + $scope.showResult = function(raw) { + $scope.lang = "json"; + $scope.sqlError = null; + $scope.sqlData = []; + + var jsonData; + try { + jsonData = JSON.parse(raw); + } catch (e) { + $scope.sqlError = raw; + return; + } + + var docs = jsonData && jsonData['result-set'] && jsonData['result-set'].docs; + if (!docs) { + var err = jsonData && jsonData.error; + if (err && err.metadata && err.metadata['root-error-class'] === 'java.lang.ClassNotFoundException' + && err.msg && err.msg.indexOf('SQLHandler') !== -1) { + $scope.sqlError = "The sql module doesn't appear to be enabled on this Solr node. " + + "See https://solr.apache.org/guide/solr/latest/query-guide/sql-query.html for how to enable it."; + } else { + $scope.sqlError = (jsonData && jsonData.message) || (err && err.msg) || raw; + } + return; + } + + //get all docs + for (var i = 0; i < docs.length; i++) { + var doc = docs[i] + //get all the properties + if(doc.hasOwnProperty("EOF")){ + if(doc.hasOwnProperty("EXCEPTION")){ + $scope.sqlError = doc.EXCEPTION + } + } else { + $scope.gridOptions.data.push(doc); + } + } + //Build the columnFields from data + var fields = $scope.gridOptions.data[1]; + for (var property in fields) { + if (fields.hasOwnProperty(property)) { + $scope.gridOptions.columnDefs.push({"name":property, "type":{}}) + } + } + $scope.gridApi.core.notifyDataChange + }; + $scope.doQuery = function() { var params = {}; @@ -47,62 +100,15 @@ solrAdminApp.controller('SQLQueryController', $scope.lang = "json"; $scope.response = null; - $scope.url = ""; $scope.gridOptions.data =[] $scope.gridOptions.columnDefs = [] - var url = Query.url(params); - - // Handles both the success and error callbacks below: app.js's global interceptor - // (see failed()'s doNotIntercept branch) routes most failures for this request through - // the "success" callback too, so this must defend against a response that isn't a SQL - // result-set either way (e.g. the sql module/handler isn't installed - SOLR-16640). - var showResult = function(raw) { - $scope.lang = "json"; - $scope.url = url; - $scope.sqlError = null; - $scope.sqlData = []; - - var jsonData; - try { - jsonData = JSON.parse(raw); - } catch (e) { - $scope.sqlError = raw; - return; - } - - var docs = jsonData && jsonData['result-set'] && jsonData['result-set'].docs; - if (!docs) { - $scope.sqlError = (jsonData && jsonData.message) || raw; - return; - } - - //get all docs - for (var i = 0; i < docs.length; i++) { - var doc = docs[i] - //get all the properties - if(doc.hasOwnProperty("EOF")){ - if(doc.hasOwnProperty("EXCEPTION")){ - $scope.sqlError = doc.EXCEPTION - } - } else { - $scope.gridOptions.data.push(doc); - } - } - //Build the columnFields from data - var fields = $scope.gridOptions.data[1]; - for (var property in fields) { - if (fields.hasOwnProperty(property)) { - $scope.gridOptions.columnDefs.push({"name":property, "type":{}}) - } - } - $scope.gridApi.core.notifyDataChange - }; + $scope.url = Query.url(params); Query.query(params, function(data) { - showResult(data.toJSON().data); + $scope.showResult(data.toJSON().data); }, function(rejection) { - showResult((rejection.data && rejection.data.data) || ("HTTP " + rejection.status + " " + rejection.statusText)); + $scope.showResult((rejection.data && rejection.data.data) || ("HTTP " + rejection.status + " " + rejection.statusText)); }); }; } From beb0226a695e97c64c77f70986169f1f071fdd91 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Wed, 7 Oct 2026 14:48:47 -0400 Subject: [PATCH 3/6] Need to add sql to our techproducts example --- solr/core/src/java/org/apache/solr/cli/RunExampleTool.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/cli/RunExampleTool.java b/solr/core/src/java/org/apache/solr/cli/RunExampleTool.java index e109022e59ab..6776ce9f7c31 100644 --- a/solr/core/src/java/org/apache/solr/cli/RunExampleTool.java +++ b/solr/core/src/java/org/apache/solr/cli/RunExampleTool.java @@ -814,7 +814,7 @@ Map startSolr( final var syspropArg = ("techproducts".equals(params.example())) - ? "-Dsolr.modules=clustering,extraction,langid,ltr,scripting -Dsolr.ltr.enabled=true -Dsolr.clustering.enabled=true" + ? "-Dsolr.modules=clustering,extraction,langid,ltr,scripting,sql -Dsolr.ltr.enabled=true -Dsolr.clustering.enabled=true" : ""; String startCmdStr = From bc6699db315f86cb3246cdf9a7273fe57feda6d7 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Wed, 7 Oct 2026 14:57:41 -0400 Subject: [PATCH 4/6] QUERY HTTP Verb for our read only SQL interface --- dev-docs/apis.adoc | 19 ++ .../solr/client/api/endpoint/QUERY.java | 30 ++++ .../solr/jersey/HttpQueryIntegrationTest.java | 168 ++++++++++++++++++ .../solrj/jetty/HttpJettySolrClient.java | 6 +- .../apache/solr/client/solrj/SolrRequest.java | 2 + .../solr/webapp/AdminUiSqlScreenTest.java | 84 ++++++++- .../web/js/angular/controllers/sqlquery.js | 19 +- solr/webapp/web/js/angular/services.js | 47 +++-- solr/webapp/web/partials/sqlquery.html | 11 +- 9 files changed, 361 insertions(+), 25 deletions(-) create mode 100644 solr/api/src/java/org/apache/solr/client/api/endpoint/QUERY.java create mode 100644 solr/core/src/test/org/apache/solr/jersey/HttpQueryIntegrationTest.java diff --git a/dev-docs/apis.adoc b/dev-docs/apis.adoc index b394b16d4522..ecf7644a5ffd 100644 --- a/dev-docs/apis.adoc +++ b/dev-docs/apis.adoc @@ -76,6 +76,25 @@ Writing a new v2 API may appear daunting, but additions in reality are actually A good example for each of these steps can be seen in Solr's v2 "add-replica-property" API, which has a defining interface https://github.com/apache/solr/blob/9426902acb7081a2e9a1fa29699c5286459e1365/solr/api/src/java/org/apache/solr/client/api/endpoint/AddReplicaPropertyApi.java[AddReplicaPropertyApi], an implementing class https://github.com/apache/solr/blob/9426902acb7081a2e9a1fa29699c5286459e1365/solr/core/src/java/org/apache/solr/handler/admin/api/AddReplicaProperty.java[AddReplicaProperty], and the two POJOs https://github.com/apache/solr/blob/main/solr/api/src/java/org/apache/solr/client/api/model/AddReplicaPropertyRequestBody.java[AddReplicaPropertyRequestBody] and https://github.com/apache/solr/blob/main/solr/api/src/java/org/apache/solr/client/api/model/SolrJerseyResponse.java[SolrJerseyResponse]. +==== HTTP QUERY spike + +Use `QUERY` for safe, idempotent queries whose inputs belong in a request body, as defined by https://www.rfc-editor.org/rfc/rfc10008.html[RFC 10008]. +For example, a search with a structured JSON body containing filters, facets, and sorting is a candidate when expressing those inputs as URL parameters would be cumbersome or exceed URL length limits. +The operation must not request changes to documents, schemas, or cluster configuration, and retrying it must not cause additional mutations. +Results can still change between requests as the underlying index changes; idempotence does not require identical results. + +Prefer `GET` for simple reads or searches that fit naturally in the URL. +Use `POST`, `PUT`, or `DELETE` for operations that request state changes, even if their inputs contain a query (such as delete-by-query). +For body-based reads, `QUERY` communicates safe retry semantics that `POST` does not; retain `POST` where client or intermediary compatibility requires it. +Only use `QUERY` on endpoints that explicitly support it, with a `Content-Type` matching the query body, such as `application/json`. + +The `QUERY` annotation in the `api` module uses JAX-RS's `@HttpMethod("QUERY")` extension mechanism. +Jetty can carry the method as a string without an entry in its `HttpMethod` enum. + +The AngularJS Admin UI's SQL screen offers a GET/POST/QUERY selector for testing against the read-only V1 `/sql` handler; POST is the default. +GET sends the SQL statement as a URL parameter; POST and QUERY send it in a form-encoded body. +Other SolrJ transports, distributed forwarding, authorization policies, OpenAPI generation, and QUERY cache semantics still need validation before adopting it for a public API. + === Relationship Between V1 and V2 Implementations Most v2 APIs have a corresponding legacy v1 API (e.g. `/admin/cores?action=RELOAD` backs `POST /api/cores/coreName/reload`). diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/QUERY.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/QUERY.java new file mode 100644 index 000000000000..7a1bb0b65741 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/QUERY.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.client.api.endpoint; + +import jakarta.ws.rs.HttpMethod; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Marks a JAX-RS resource method as accepting HTTP QUERY requests. */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@HttpMethod("QUERY") +public @interface QUERY {} diff --git a/solr/core/src/test/org/apache/solr/jersey/HttpQueryIntegrationTest.java b/solr/core/src/test/org/apache/solr/jersey/HttpQueryIntegrationTest.java new file mode 100644 index 000000000000..eb2a2d09d4a6 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/jersey/HttpQueryIntegrationTest.java @@ -0,0 +1,168 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.jersey; + +import static org.apache.solr.SolrTestCaseJ4.TEST_PATH; +import static org.apache.solr.SolrTestCaseJ4.copyMinConf; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import org.apache.solr.SolrTestCase; +import org.apache.solr.api.JerseyResource; +import org.apache.solr.client.api.endpoint.QUERY; +import org.apache.solr.client.solrj.SolrRequest; +import org.apache.solr.client.solrj.jetty.HttpJettySolrClient; +import org.apache.solr.client.solrj.request.V2Request; +import org.apache.solr.client.solrj.response.json.JsonMapResponseParser; +import org.apache.solr.common.util.Utils; +import org.apache.solr.handler.RequestHandlerBase; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.response.SolrQueryResponse; +import org.apache.solr.security.AuthorizationContext; +import org.apache.solr.security.PermissionNameProvider; +import org.apache.solr.util.SolrJettyTestRule; +import org.eclipse.jetty.client.StringRequestContent; +import org.eclipse.jetty.http.HttpVersion; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; + +/** Exercises an extension HTTP method through Jetty, Solr dispatch, and Jersey. */ +public class HttpQueryIntegrationTest extends SolrTestCase { + @ClassRule public static final SolrJettyTestRule solrTestRule = new SolrJettyTestRule(); + + @BeforeClass + public static void setupSolr() throws Exception { + final var solrHome = createTempDir(); + Files.copy(TEST_PATH().resolve("solr.xml"), solrHome.resolve("solr.xml")); + final var coreDir = solrHome.resolve("collection1"); + copyMinConf(coreDir, "name=collection1\n", "solrconfig-minimal.xml"); + final var config = coreDir.resolve("conf/solrconfig.xml"); + Files.writeString( + config, + Files.readString(config) + .replaceAll("]*/>", "") + .replace( + "", + "\n")); + solrTestRule.startSolr(solrHome); + } + + @Test + public void testQueryWithJsonBody() throws Exception { + for (var version : List.of(HttpVersion.HTTP_1_1, HttpVersion.HTTP_2)) { + try (var solrClient = + new HttpJettySolrClient.Builder(solrTestRule.getBaseUrl()) + .useHttp1_1(version == HttpVersion.HTTP_1_1) + .build()) { + final var response = + solrClient + .getHttpClient() + .newRequest(queryUrl()) + .version(version) + .method("QUERY") + .body( + new StringRequestContent( + "application/json", "{\"query\":\"id:42\"}", StandardCharsets.UTF_8)) + .send(); + assertEquals(response.getContentAsString(), 200, response.getStatus()); + assertEquals(version, response.getVersion()); + assertEquals( + "id:42", + ((Map) Utils.fromJSONString(response.getContentAsString())).get("query")); + } + } + } + + @Test + public void testQueryThroughSolrJ() throws Exception { + final var request = + new V2Request.Builder("/cores/collection1/query-spike") + .withMethod(SolrRequest.METHOD.QUERY) + .withPayload("{\"query\":\"id:42\"}") + .build(); + request.setResponseParser(new JsonMapResponseParser()); + assertEquals("id:42", solrTestRule.getJetty().getSolrClient().request(request).get("query")); + } + + @Test + public void testPostDoesNotInvokeQueryEndpoint() throws Exception { + final var response = + solrTestRule + .getJetty() + .getSolrClient() + .getHttpClient() + .newRequest(queryUrl()) + .method("POST") + .body( + new StringRequestContent( + "application/json", "{\"query\":\"id:42\"}", StandardCharsets.UTF_8)) + .send(); + assertEquals(response.getContentAsString(), 405, response.getStatus()); + } + + private String queryUrl() { + return solrTestRule.getJetty().getBaseURLV2() + "/cores/collection1/query-spike"; + } + + /** Registers the test resource with the core's Jersey application. */ + public static class QueryHandler extends RequestHandlerBase { + @Override + public PermissionNameProvider.Name getPermissionName(AuthorizationContext request) { + return PermissionNameProvider.Name.READ_PERM; + } + + @Override + public Boolean registerV2() { + return true; + } + + @Override + public Collection> getJerseyResources() { + return List.of(QueryResource.class); + } + + @Override + public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) {} + + @Override + public String getDescription() { + return "HTTP QUERY spike"; + } + } + + /** Test-only V2 endpoint that echoes the JSON query to verify entity handling. */ + @Path("/cores/{coreName}/query-spike") + public static class QueryResource extends JerseyResource { + @QUERY + @Consumes("application/json") + @Produces("application/json") + @PermissionName(PermissionNameProvider.Name.READ_PERM) + public Map query(Map body) { + return body; + } + } +} diff --git a/solr/solrj-jetty/src/java/org/apache/solr/client/solrj/jetty/HttpJettySolrClient.java b/solr/solrj-jetty/src/java/org/apache/solr/client/solrj/jetty/HttpJettySolrClient.java index cf05fdac07ec..6c7ea58eec4a 100644 --- a/solr/solrj-jetty/src/java/org/apache/solr/client/solrj/jetty/HttpJettySolrClient.java +++ b/solr/solrj-jetty/src/java/org/apache/solr/client/solrj/jetty/HttpJettySolrClient.java @@ -719,11 +719,11 @@ private MakeRequestReturnValue makeRequest( } if (SolrRequest.METHOD.POST == solrRequest.getMethod() - || SolrRequest.METHOD.PUT == solrRequest.getMethod()) { + || SolrRequest.METHOD.PUT == solrRequest.getMethod() + || SolrRequest.METHOD.QUERY == solrRequest.getMethod()) { RequestWriter.ContentWriter contentWriter = requestWriter.getContentWriter(solrRequest); - HttpMethod method = - SolrRequest.METHOD.POST == solrRequest.getMethod() ? HttpMethod.POST : HttpMethod.PUT; + String method = solrRequest.getMethod().toString(); if (contentWriter instanceof RequestWriter.MultipartContentWriter multipartWriter) { // send server list and request list as query string params diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java b/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java index 4e0429e124fd..56f552f192b9 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java @@ -63,6 +63,7 @@ public enum METHOD { HEAD, POST, PUT, + QUERY, DELETE; /** @@ -113,6 +114,7 @@ public enum SolrClientContext { METHOD.GET.toString(), METHOD.POST.toString(), METHOD.PUT.toString(), + METHOD.QUERY.toString(), METHOD.DELETE.toString()); private METHOD method = METHOD.GET; diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java index 3ee818f3b11a..42a2c6130381 100644 --- a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java @@ -16,6 +16,7 @@ */ package org.apache.solr.webapp; +import java.util.Locale; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.common.SolrInputDocument; import org.junit.BeforeClass; @@ -46,16 +47,85 @@ public static void setupCollection() throws Exception { @Test public void testSqlQueryViaUi() { + assertSqlQueryViaUi("POST", "SELECT id FROM " + COLLECTION + " LIMIT 10"); + } + + @Test + public void testSqlQueryViaQuery() { + assertSqlQueryViaUi("QUERY", "SELECT id FROM " + COLLECTION + " LIMIT 10"); + } + + @Test + public void testSqlQueryViaGet() { + assertSqlQueryViaUi("GET", "SELECT id FROM " + COLLECTION + " LIMIT 10"); + } + + @Test + public void testLargeSqlStatementViaPost() { + assertSqlQueryViaUi("POST", largeStatement()); + } + + @Test + public void testLargeSqlStatementViaQuery() { + assertSqlQueryViaUi("QUERY", largeStatement()); + } + + private String largeStatement() { + return "SELECT /* " + "a".repeat(20000) + " */ id FROM " + COLLECTION + " LIMIT 10"; + } + + private void assertSqlQueryViaUi(String method, String statement) { openPage(COLLECTION + "/sqlquery", By.id("sqlquery")); + WebElement selector = waitFor(By.id("httpMethod")); + assertEquals("POST", selector.getDomProperty("value")); + selector.findElement(By.cssSelector("option[value='" + method + "']")).click(); + ((JavascriptExecutor) driver) + .executeScript( + "window.sqlRequest = null;" + + "var originalOpen = XMLHttpRequest.prototype.open;" + + "var originalSend = XMLHttpRequest.prototype.send;" + + "XMLHttpRequest.prototype.open = function(method, url) {" + + " if (/\\/sql(?:\\?|$)/.test(url)) {" + + " this.sqlRequest = window.sqlRequest = {method: method, url: url};" + + " }" + + " return originalOpen.apply(this, arguments);" + + "};" + + "XMLHttpRequest.prototype.send = function(body) {" + + " if (this.sqlRequest) this.sqlRequest.body = body;" + + " return originalSend.apply(this, arguments);" + + "};"); WebElement stmt = waitFor(By.id("sqlexp")); - stmt.clear(); - stmt.sendKeys("SELECT id FROM " + COLLECTION + " LIMIT 10"); + ((JavascriptExecutor) driver) + .executeScript( + "arguments[0].value = arguments[1];" + + "arguments[0].dispatchEvent(new Event('input', {bubbles: true}));", + stmt, + statement); click(By.xpath("//div[@id='sqlquery']//button[@type='submit']")); // the result grid lists all documents for (int i = 1; i <= 3; i++) { waitForPageContains("sql-doc-" + i); } + assertEquals( + method, ((JavascriptExecutor) driver).executeScript("return window.sqlRequest.method;")); + if ("GET".equals(method)) { + assertNull(((JavascriptExecutor) driver).executeScript("return window.sqlRequest.body;")); + assertEquals( + statement, + ((JavascriptExecutor) driver) + .executeScript( + "return new URL(window.sqlRequest.url, window.location.href).searchParams.get('stmt');")); + } else { + assertEquals( + "stmt=" + statement, + ((JavascriptExecutor) driver) + .executeScript("return decodeURIComponent(window.sqlRequest.body);")); + assertEquals( + false, + ((JavascriptExecutor) driver) + .executeScript("return window.sqlRequest.url.includes('stmt=');")); + } assertNoSevereConsoleErrors(); } @@ -101,8 +171,14 @@ public void testSqlModuleNotEnabledShowsFriendlyMessage() { classNotFoundResponse); assertTrue( "should show a friendly message, got: " + sqlError, - sqlError != null && sqlError.toLowerCase().contains("sql module")); + sqlError != null && sqlError.toLowerCase(Locale.ROOT).contains("sql module")); assertTrue( - "should say how to fix it: " + sqlError, sqlError.toLowerCase().contains("enable")); + "should say how to fix it: " + sqlError, + sqlError.toLowerCase(Locale.ROOT).contains("enable")); + WebElement documentation = waitFor(By.cssSelector("#sql-response span a")); + assertEquals( + "https://solr.apache.org/guide/solr/latest/query-guide/sql-query.html", + documentation.getDomAttribute("href")); + assertEquals("_out", documentation.getDomAttribute("target")); } } diff --git a/solr/webapp/web/js/angular/controllers/sqlquery.js b/solr/webapp/web/js/angular/controllers/sqlquery.js index 425337ce9808..682876995f10 100644 --- a/solr/webapp/web/js/angular/controllers/sqlquery.js +++ b/solr/webapp/web/js/angular/controllers/sqlquery.js @@ -19,6 +19,7 @@ solrAdminApp.controller('SQLQueryController', $scope.resetMenu("sqlquery", Constants.IS_COLLECTION_PAGE); $scope.qt = "sql"; + $scope.httpMethod = "POST"; $scope.doExplanation = false $scope.gridOptions = { enableSorting: false, @@ -39,6 +40,7 @@ solrAdminApp.controller('SQLQueryController', $scope.showResult = function(raw) { $scope.lang = "json"; $scope.sqlError = null; + $scope.sqlModuleMissing = false; $scope.sqlData = []; var jsonData; @@ -54,8 +56,8 @@ solrAdminApp.controller('SQLQueryController', var err = jsonData && jsonData.error; if (err && err.metadata && err.metadata['root-error-class'] === 'java.lang.ClassNotFoundException' && err.msg && err.msg.indexOf('SQLHandler') !== -1) { - $scope.sqlError = "The sql module doesn't appear to be enabled on this Solr node. " + - "See https://solr.apache.org/guide/solr/latest/query-guide/sql-query.html for how to enable it."; + $scope.sqlModuleMissing = true; + $scope.sqlError = "The sql module doesn't appear to be enabled on this Solr node."; } else { $scope.sqlError = (jsonData && jsonData.message) || (err && err.msg) || raw; } @@ -105,11 +107,18 @@ solrAdminApp.controller('SQLQueryController', $scope.url = Query.url(params); - Query.query(params, function(data) { + var onSuccess = function(data) { $scope.showResult(data.toJSON().data); - }, function(rejection) { + }; + var onError = function(rejection) { $scope.showResult((rejection.data && rejection.data.data) || ("HTTP " + rejection.status + " " + rejection.statusText)); - }); + }; + if ($scope.httpMethod === "GET") { + Query.query(params, onSuccess, onError); + } else { + var sendRequest = $scope.httpMethod === "QUERY" ? Query.queryQuery : Query.queryPost; + sendRequest({core: params.core, handler: params.handler}, params, onSuccess, onError); + } }; } ); diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index 462d684121e0..bae76d8612e0 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -355,25 +355,48 @@ solrAdminServices.factory('Metrics', }]) .factory('Query', ['$resource', function($resource) { - var resource = $resource(':core/:handler', {core: '@core', handler: '@handler', '_':Date.now()}, { - "query": { - method: "GET", - transformResponse: function (data) { - return {data: data} - }, - headers: {doNotIntercept: "true"} - } - }); - resource.url = function(params) { + var toQueryString = function(params) { var qs = []; - for (key in params) { + for (var key in params) { if (key != "core" && key != "handler") { for (var i in params[key]) { qs.push(key + "=" + encodeURIComponent(params[key][i])); } } } - return "" + params.core + "/" + params.handler + "?" + qs.sort().join("&"); + return qs.sort().join("&"); + } + var wrapRawResponse = function(data) { + return {data: data} + } + var resource = $resource(':core/:handler', {core: '@core', handler: '@handler', '_':Date.now()}, { + "query": { + method: "GET", + transformResponse: wrapRawResponse, + headers: {doNotIntercept: "true"} + }, + "queryPost": { + method: "POST", + transformRequest: toQueryString, + transformResponse: wrapRawResponse, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + doNotIntercept: "true" + } + }, + "queryQuery": { + method: "QUERY", + hasBody: true, + transformRequest: toQueryString, + transformResponse: wrapRawResponse, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + doNotIntercept: "true" + } + } + }); + resource.url = function(params) { + return "" + params.core + "/" + params.handler + "?" + toQueryString(params); } return resource; }]) diff --git a/solr/webapp/web/partials/sqlquery.html b/solr/webapp/web/partials/sqlquery.html index c0254d13ac34..4acce9674636 100644 --- a/solr/webapp/web/partials/sqlquery.html +++ b/solr/webapp/web/partials/sqlquery.html @@ -22,13 +22,22 @@ SQL Query Statement + + syntax help
{{hostPortContext}}{{url}} -
{{sqlError}}
+
+ {{sqlError}} + See SQL documentation for how to enable it. +
From e5fadc3389a4cb8b5996c3cc0357ba7168f92dfa Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Wed, 7 Oct 2026 15:04:46 -0400 Subject: [PATCH 5/6] SOLR-16640: Separate the HTTP QUERY demonstration from SQL error handling --- dev-docs/apis.adoc | 19 -- .../solr/client/api/endpoint/QUERY.java | 30 ---- .../solr/jersey/HttpQueryIntegrationTest.java | 168 ------------------ .../solrj/jetty/HttpJettySolrClient.java | 6 +- .../apache/solr/client/solrj/SolrRequest.java | 2 - .../solr/webapp/AdminUiSqlScreenTest.java | 73 +------- .../web/js/angular/controllers/sqlquery.js | 14 +- solr/webapp/web/js/angular/services.js | 47 ++--- solr/webapp/web/partials/sqlquery.html | 6 - 9 files changed, 20 insertions(+), 345 deletions(-) delete mode 100644 solr/api/src/java/org/apache/solr/client/api/endpoint/QUERY.java delete mode 100644 solr/core/src/test/org/apache/solr/jersey/HttpQueryIntegrationTest.java diff --git a/dev-docs/apis.adoc b/dev-docs/apis.adoc index ecf7644a5ffd..b394b16d4522 100644 --- a/dev-docs/apis.adoc +++ b/dev-docs/apis.adoc @@ -76,25 +76,6 @@ Writing a new v2 API may appear daunting, but additions in reality are actually A good example for each of these steps can be seen in Solr's v2 "add-replica-property" API, which has a defining interface https://github.com/apache/solr/blob/9426902acb7081a2e9a1fa29699c5286459e1365/solr/api/src/java/org/apache/solr/client/api/endpoint/AddReplicaPropertyApi.java[AddReplicaPropertyApi], an implementing class https://github.com/apache/solr/blob/9426902acb7081a2e9a1fa29699c5286459e1365/solr/core/src/java/org/apache/solr/handler/admin/api/AddReplicaProperty.java[AddReplicaProperty], and the two POJOs https://github.com/apache/solr/blob/main/solr/api/src/java/org/apache/solr/client/api/model/AddReplicaPropertyRequestBody.java[AddReplicaPropertyRequestBody] and https://github.com/apache/solr/blob/main/solr/api/src/java/org/apache/solr/client/api/model/SolrJerseyResponse.java[SolrJerseyResponse]. -==== HTTP QUERY spike - -Use `QUERY` for safe, idempotent queries whose inputs belong in a request body, as defined by https://www.rfc-editor.org/rfc/rfc10008.html[RFC 10008]. -For example, a search with a structured JSON body containing filters, facets, and sorting is a candidate when expressing those inputs as URL parameters would be cumbersome or exceed URL length limits. -The operation must not request changes to documents, schemas, or cluster configuration, and retrying it must not cause additional mutations. -Results can still change between requests as the underlying index changes; idempotence does not require identical results. - -Prefer `GET` for simple reads or searches that fit naturally in the URL. -Use `POST`, `PUT`, or `DELETE` for operations that request state changes, even if their inputs contain a query (such as delete-by-query). -For body-based reads, `QUERY` communicates safe retry semantics that `POST` does not; retain `POST` where client or intermediary compatibility requires it. -Only use `QUERY` on endpoints that explicitly support it, with a `Content-Type` matching the query body, such as `application/json`. - -The `QUERY` annotation in the `api` module uses JAX-RS's `@HttpMethod("QUERY")` extension mechanism. -Jetty can carry the method as a string without an entry in its `HttpMethod` enum. - -The AngularJS Admin UI's SQL screen offers a GET/POST/QUERY selector for testing against the read-only V1 `/sql` handler; POST is the default. -GET sends the SQL statement as a URL parameter; POST and QUERY send it in a form-encoded body. -Other SolrJ transports, distributed forwarding, authorization policies, OpenAPI generation, and QUERY cache semantics still need validation before adopting it for a public API. - === Relationship Between V1 and V2 Implementations Most v2 APIs have a corresponding legacy v1 API (e.g. `/admin/cores?action=RELOAD` backs `POST /api/cores/coreName/reload`). diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/QUERY.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/QUERY.java deleted file mode 100644 index 7a1bb0b65741..000000000000 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/QUERY.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.solr.client.api.endpoint; - -import jakarta.ws.rs.HttpMethod; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** Marks a JAX-RS resource method as accepting HTTP QUERY requests. */ -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -@HttpMethod("QUERY") -public @interface QUERY {} diff --git a/solr/core/src/test/org/apache/solr/jersey/HttpQueryIntegrationTest.java b/solr/core/src/test/org/apache/solr/jersey/HttpQueryIntegrationTest.java deleted file mode 100644 index eb2a2d09d4a6..000000000000 --- a/solr/core/src/test/org/apache/solr/jersey/HttpQueryIntegrationTest.java +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.solr.jersey; - -import static org.apache.solr.SolrTestCaseJ4.TEST_PATH; -import static org.apache.solr.SolrTestCaseJ4.copyMinConf; - -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import org.apache.solr.SolrTestCase; -import org.apache.solr.api.JerseyResource; -import org.apache.solr.client.api.endpoint.QUERY; -import org.apache.solr.client.solrj.SolrRequest; -import org.apache.solr.client.solrj.jetty.HttpJettySolrClient; -import org.apache.solr.client.solrj.request.V2Request; -import org.apache.solr.client.solrj.response.json.JsonMapResponseParser; -import org.apache.solr.common.util.Utils; -import org.apache.solr.handler.RequestHandlerBase; -import org.apache.solr.request.SolrQueryRequest; -import org.apache.solr.response.SolrQueryResponse; -import org.apache.solr.security.AuthorizationContext; -import org.apache.solr.security.PermissionNameProvider; -import org.apache.solr.util.SolrJettyTestRule; -import org.eclipse.jetty.client.StringRequestContent; -import org.eclipse.jetty.http.HttpVersion; -import org.junit.BeforeClass; -import org.junit.ClassRule; -import org.junit.Test; - -/** Exercises an extension HTTP method through Jetty, Solr dispatch, and Jersey. */ -public class HttpQueryIntegrationTest extends SolrTestCase { - @ClassRule public static final SolrJettyTestRule solrTestRule = new SolrJettyTestRule(); - - @BeforeClass - public static void setupSolr() throws Exception { - final var solrHome = createTempDir(); - Files.copy(TEST_PATH().resolve("solr.xml"), solrHome.resolve("solr.xml")); - final var coreDir = solrHome.resolve("collection1"); - copyMinConf(coreDir, "name=collection1\n", "solrconfig-minimal.xml"); - final var config = coreDir.resolve("conf/solrconfig.xml"); - Files.writeString( - config, - Files.readString(config) - .replaceAll("]*/>", "") - .replace( - "", - "\n")); - solrTestRule.startSolr(solrHome); - } - - @Test - public void testQueryWithJsonBody() throws Exception { - for (var version : List.of(HttpVersion.HTTP_1_1, HttpVersion.HTTP_2)) { - try (var solrClient = - new HttpJettySolrClient.Builder(solrTestRule.getBaseUrl()) - .useHttp1_1(version == HttpVersion.HTTP_1_1) - .build()) { - final var response = - solrClient - .getHttpClient() - .newRequest(queryUrl()) - .version(version) - .method("QUERY") - .body( - new StringRequestContent( - "application/json", "{\"query\":\"id:42\"}", StandardCharsets.UTF_8)) - .send(); - assertEquals(response.getContentAsString(), 200, response.getStatus()); - assertEquals(version, response.getVersion()); - assertEquals( - "id:42", - ((Map) Utils.fromJSONString(response.getContentAsString())).get("query")); - } - } - } - - @Test - public void testQueryThroughSolrJ() throws Exception { - final var request = - new V2Request.Builder("/cores/collection1/query-spike") - .withMethod(SolrRequest.METHOD.QUERY) - .withPayload("{\"query\":\"id:42\"}") - .build(); - request.setResponseParser(new JsonMapResponseParser()); - assertEquals("id:42", solrTestRule.getJetty().getSolrClient().request(request).get("query")); - } - - @Test - public void testPostDoesNotInvokeQueryEndpoint() throws Exception { - final var response = - solrTestRule - .getJetty() - .getSolrClient() - .getHttpClient() - .newRequest(queryUrl()) - .method("POST") - .body( - new StringRequestContent( - "application/json", "{\"query\":\"id:42\"}", StandardCharsets.UTF_8)) - .send(); - assertEquals(response.getContentAsString(), 405, response.getStatus()); - } - - private String queryUrl() { - return solrTestRule.getJetty().getBaseURLV2() + "/cores/collection1/query-spike"; - } - - /** Registers the test resource with the core's Jersey application. */ - public static class QueryHandler extends RequestHandlerBase { - @Override - public PermissionNameProvider.Name getPermissionName(AuthorizationContext request) { - return PermissionNameProvider.Name.READ_PERM; - } - - @Override - public Boolean registerV2() { - return true; - } - - @Override - public Collection> getJerseyResources() { - return List.of(QueryResource.class); - } - - @Override - public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) {} - - @Override - public String getDescription() { - return "HTTP QUERY spike"; - } - } - - /** Test-only V2 endpoint that echoes the JSON query to verify entity handling. */ - @Path("/cores/{coreName}/query-spike") - public static class QueryResource extends JerseyResource { - @QUERY - @Consumes("application/json") - @Produces("application/json") - @PermissionName(PermissionNameProvider.Name.READ_PERM) - public Map query(Map body) { - return body; - } - } -} diff --git a/solr/solrj-jetty/src/java/org/apache/solr/client/solrj/jetty/HttpJettySolrClient.java b/solr/solrj-jetty/src/java/org/apache/solr/client/solrj/jetty/HttpJettySolrClient.java index 6c7ea58eec4a..cf05fdac07ec 100644 --- a/solr/solrj-jetty/src/java/org/apache/solr/client/solrj/jetty/HttpJettySolrClient.java +++ b/solr/solrj-jetty/src/java/org/apache/solr/client/solrj/jetty/HttpJettySolrClient.java @@ -719,11 +719,11 @@ private MakeRequestReturnValue makeRequest( } if (SolrRequest.METHOD.POST == solrRequest.getMethod() - || SolrRequest.METHOD.PUT == solrRequest.getMethod() - || SolrRequest.METHOD.QUERY == solrRequest.getMethod()) { + || SolrRequest.METHOD.PUT == solrRequest.getMethod()) { RequestWriter.ContentWriter contentWriter = requestWriter.getContentWriter(solrRequest); - String method = solrRequest.getMethod().toString(); + HttpMethod method = + SolrRequest.METHOD.POST == solrRequest.getMethod() ? HttpMethod.POST : HttpMethod.PUT; if (contentWriter instanceof RequestWriter.MultipartContentWriter multipartWriter) { // send server list and request list as query string params diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java b/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java index 56f552f192b9..4e0429e124fd 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java @@ -63,7 +63,6 @@ public enum METHOD { HEAD, POST, PUT, - QUERY, DELETE; /** @@ -114,7 +113,6 @@ public enum SolrClientContext { METHOD.GET.toString(), METHOD.POST.toString(), METHOD.PUT.toString(), - METHOD.QUERY.toString(), METHOD.DELETE.toString()); private METHOD method = METHOD.GET; diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java index 42a2c6130381..e6b80030677c 100644 --- a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java @@ -47,85 +47,16 @@ public static void setupCollection() throws Exception { @Test public void testSqlQueryViaUi() { - assertSqlQueryViaUi("POST", "SELECT id FROM " + COLLECTION + " LIMIT 10"); - } - - @Test - public void testSqlQueryViaQuery() { - assertSqlQueryViaUi("QUERY", "SELECT id FROM " + COLLECTION + " LIMIT 10"); - } - - @Test - public void testSqlQueryViaGet() { - assertSqlQueryViaUi("GET", "SELECT id FROM " + COLLECTION + " LIMIT 10"); - } - - @Test - public void testLargeSqlStatementViaPost() { - assertSqlQueryViaUi("POST", largeStatement()); - } - - @Test - public void testLargeSqlStatementViaQuery() { - assertSqlQueryViaUi("QUERY", largeStatement()); - } - - private String largeStatement() { - return "SELECT /* " + "a".repeat(20000) + " */ id FROM " + COLLECTION + " LIMIT 10"; - } - - private void assertSqlQueryViaUi(String method, String statement) { openPage(COLLECTION + "/sqlquery", By.id("sqlquery")); - WebElement selector = waitFor(By.id("httpMethod")); - assertEquals("POST", selector.getDomProperty("value")); - selector.findElement(By.cssSelector("option[value='" + method + "']")).click(); - ((JavascriptExecutor) driver) - .executeScript( - "window.sqlRequest = null;" - + "var originalOpen = XMLHttpRequest.prototype.open;" - + "var originalSend = XMLHttpRequest.prototype.send;" - + "XMLHttpRequest.prototype.open = function(method, url) {" - + " if (/\\/sql(?:\\?|$)/.test(url)) {" - + " this.sqlRequest = window.sqlRequest = {method: method, url: url};" - + " }" - + " return originalOpen.apply(this, arguments);" - + "};" - + "XMLHttpRequest.prototype.send = function(body) {" - + " if (this.sqlRequest) this.sqlRequest.body = body;" - + " return originalSend.apply(this, arguments);" - + "};"); WebElement stmt = waitFor(By.id("sqlexp")); - ((JavascriptExecutor) driver) - .executeScript( - "arguments[0].value = arguments[1];" - + "arguments[0].dispatchEvent(new Event('input', {bubbles: true}));", - stmt, - statement); + stmt.clear(); + stmt.sendKeys("SELECT id FROM " + COLLECTION + " LIMIT 10"); click(By.xpath("//div[@id='sqlquery']//button[@type='submit']")); // the result grid lists all documents for (int i = 1; i <= 3; i++) { waitForPageContains("sql-doc-" + i); } - assertEquals( - method, ((JavascriptExecutor) driver).executeScript("return window.sqlRequest.method;")); - if ("GET".equals(method)) { - assertNull(((JavascriptExecutor) driver).executeScript("return window.sqlRequest.body;")); - assertEquals( - statement, - ((JavascriptExecutor) driver) - .executeScript( - "return new URL(window.sqlRequest.url, window.location.href).searchParams.get('stmt');")); - } else { - assertEquals( - "stmt=" + statement, - ((JavascriptExecutor) driver) - .executeScript("return decodeURIComponent(window.sqlRequest.body);")); - assertEquals( - false, - ((JavascriptExecutor) driver) - .executeScript("return window.sqlRequest.url.includes('stmt=');")); - } assertNoSevereConsoleErrors(); } diff --git a/solr/webapp/web/js/angular/controllers/sqlquery.js b/solr/webapp/web/js/angular/controllers/sqlquery.js index 682876995f10..67d0f8cf7882 100644 --- a/solr/webapp/web/js/angular/controllers/sqlquery.js +++ b/solr/webapp/web/js/angular/controllers/sqlquery.js @@ -19,7 +19,6 @@ solrAdminApp.controller('SQLQueryController', $scope.resetMenu("sqlquery", Constants.IS_COLLECTION_PAGE); $scope.qt = "sql"; - $scope.httpMethod = "POST"; $scope.doExplanation = false $scope.gridOptions = { enableSorting: false, @@ -107,18 +106,11 @@ solrAdminApp.controller('SQLQueryController', $scope.url = Query.url(params); - var onSuccess = function(data) { + Query.query(params, function(data) { $scope.showResult(data.toJSON().data); - }; - var onError = function(rejection) { + }, function(rejection) { $scope.showResult((rejection.data && rejection.data.data) || ("HTTP " + rejection.status + " " + rejection.statusText)); - }; - if ($scope.httpMethod === "GET") { - Query.query(params, onSuccess, onError); - } else { - var sendRequest = $scope.httpMethod === "QUERY" ? Query.queryQuery : Query.queryPost; - sendRequest({core: params.core, handler: params.handler}, params, onSuccess, onError); - } + }); }; } ); diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index bae76d8612e0..462d684121e0 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -355,48 +355,25 @@ solrAdminServices.factory('Metrics', }]) .factory('Query', ['$resource', function($resource) { - var toQueryString = function(params) { - var qs = []; - for (var key in params) { - if (key != "core" && key != "handler") { - for (var i in params[key]) { - qs.push(key + "=" + encodeURIComponent(params[key][i])); - } - } - } - return qs.sort().join("&"); - } - var wrapRawResponse = function(data) { - return {data: data} - } var resource = $resource(':core/:handler', {core: '@core', handler: '@handler', '_':Date.now()}, { "query": { method: "GET", - transformResponse: wrapRawResponse, + transformResponse: function (data) { + return {data: data} + }, headers: {doNotIntercept: "true"} - }, - "queryPost": { - method: "POST", - transformRequest: toQueryString, - transformResponse: wrapRawResponse, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - doNotIntercept: "true" - } - }, - "queryQuery": { - method: "QUERY", - hasBody: true, - transformRequest: toQueryString, - transformResponse: wrapRawResponse, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - doNotIntercept: "true" - } } }); resource.url = function(params) { - return "" + params.core + "/" + params.handler + "?" + toQueryString(params); + var qs = []; + for (key in params) { + if (key != "core" && key != "handler") { + for (var i in params[key]) { + qs.push(key + "=" + encodeURIComponent(params[key][i])); + } + } + } + return "" + params.core + "/" + params.handler + "?" + qs.sort().join("&"); } return resource; }]) diff --git a/solr/webapp/web/partials/sqlquery.html b/solr/webapp/web/partials/sqlquery.html index 4acce9674636..76a2506c061b 100644 --- a/solr/webapp/web/partials/sqlquery.html +++ b/solr/webapp/web/partials/sqlquery.html @@ -22,12 +22,6 @@ SQL Query Statement - - syntax help From 0b81777fef9a7f35aa995c74dd83f98d600b2382 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Wed, 7 Oct 2026 15:05:09 -0400 Subject: [PATCH 6/6] Demonstrate HTTP QUERY for the read-only SQL interface --- dev-docs/apis.adoc | 19 ++ .../solr/client/api/endpoint/QUERY.java | 30 ++++ .../solr/jersey/HttpQueryIntegrationTest.java | 168 ++++++++++++++++++ .../solrj/jetty/HttpJettySolrClient.java | 6 +- .../apache/solr/client/solrj/SolrRequest.java | 2 + .../solr/webapp/AdminUiSqlScreenTest.java | 73 +++++++- .../web/js/angular/controllers/sqlquery.js | 14 +- solr/webapp/web/js/angular/services.js | 47 +++-- solr/webapp/web/partials/sqlquery.html | 6 + 9 files changed, 345 insertions(+), 20 deletions(-) create mode 100644 solr/api/src/java/org/apache/solr/client/api/endpoint/QUERY.java create mode 100644 solr/core/src/test/org/apache/solr/jersey/HttpQueryIntegrationTest.java diff --git a/dev-docs/apis.adoc b/dev-docs/apis.adoc index b394b16d4522..ecf7644a5ffd 100644 --- a/dev-docs/apis.adoc +++ b/dev-docs/apis.adoc @@ -76,6 +76,25 @@ Writing a new v2 API may appear daunting, but additions in reality are actually A good example for each of these steps can be seen in Solr's v2 "add-replica-property" API, which has a defining interface https://github.com/apache/solr/blob/9426902acb7081a2e9a1fa29699c5286459e1365/solr/api/src/java/org/apache/solr/client/api/endpoint/AddReplicaPropertyApi.java[AddReplicaPropertyApi], an implementing class https://github.com/apache/solr/blob/9426902acb7081a2e9a1fa29699c5286459e1365/solr/core/src/java/org/apache/solr/handler/admin/api/AddReplicaProperty.java[AddReplicaProperty], and the two POJOs https://github.com/apache/solr/blob/main/solr/api/src/java/org/apache/solr/client/api/model/AddReplicaPropertyRequestBody.java[AddReplicaPropertyRequestBody] and https://github.com/apache/solr/blob/main/solr/api/src/java/org/apache/solr/client/api/model/SolrJerseyResponse.java[SolrJerseyResponse]. +==== HTTP QUERY spike + +Use `QUERY` for safe, idempotent queries whose inputs belong in a request body, as defined by https://www.rfc-editor.org/rfc/rfc10008.html[RFC 10008]. +For example, a search with a structured JSON body containing filters, facets, and sorting is a candidate when expressing those inputs as URL parameters would be cumbersome or exceed URL length limits. +The operation must not request changes to documents, schemas, or cluster configuration, and retrying it must not cause additional mutations. +Results can still change between requests as the underlying index changes; idempotence does not require identical results. + +Prefer `GET` for simple reads or searches that fit naturally in the URL. +Use `POST`, `PUT`, or `DELETE` for operations that request state changes, even if their inputs contain a query (such as delete-by-query). +For body-based reads, `QUERY` communicates safe retry semantics that `POST` does not; retain `POST` where client or intermediary compatibility requires it. +Only use `QUERY` on endpoints that explicitly support it, with a `Content-Type` matching the query body, such as `application/json`. + +The `QUERY` annotation in the `api` module uses JAX-RS's `@HttpMethod("QUERY")` extension mechanism. +Jetty can carry the method as a string without an entry in its `HttpMethod` enum. + +The AngularJS Admin UI's SQL screen offers a GET/POST/QUERY selector for testing against the read-only V1 `/sql` handler; POST is the default. +GET sends the SQL statement as a URL parameter; POST and QUERY send it in a form-encoded body. +Other SolrJ transports, distributed forwarding, authorization policies, OpenAPI generation, and QUERY cache semantics still need validation before adopting it for a public API. + === Relationship Between V1 and V2 Implementations Most v2 APIs have a corresponding legacy v1 API (e.g. `/admin/cores?action=RELOAD` backs `POST /api/cores/coreName/reload`). diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/QUERY.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/QUERY.java new file mode 100644 index 000000000000..7a1bb0b65741 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/QUERY.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.client.api.endpoint; + +import jakarta.ws.rs.HttpMethod; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Marks a JAX-RS resource method as accepting HTTP QUERY requests. */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@HttpMethod("QUERY") +public @interface QUERY {} diff --git a/solr/core/src/test/org/apache/solr/jersey/HttpQueryIntegrationTest.java b/solr/core/src/test/org/apache/solr/jersey/HttpQueryIntegrationTest.java new file mode 100644 index 000000000000..eb2a2d09d4a6 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/jersey/HttpQueryIntegrationTest.java @@ -0,0 +1,168 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.jersey; + +import static org.apache.solr.SolrTestCaseJ4.TEST_PATH; +import static org.apache.solr.SolrTestCaseJ4.copyMinConf; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import org.apache.solr.SolrTestCase; +import org.apache.solr.api.JerseyResource; +import org.apache.solr.client.api.endpoint.QUERY; +import org.apache.solr.client.solrj.SolrRequest; +import org.apache.solr.client.solrj.jetty.HttpJettySolrClient; +import org.apache.solr.client.solrj.request.V2Request; +import org.apache.solr.client.solrj.response.json.JsonMapResponseParser; +import org.apache.solr.common.util.Utils; +import org.apache.solr.handler.RequestHandlerBase; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.response.SolrQueryResponse; +import org.apache.solr.security.AuthorizationContext; +import org.apache.solr.security.PermissionNameProvider; +import org.apache.solr.util.SolrJettyTestRule; +import org.eclipse.jetty.client.StringRequestContent; +import org.eclipse.jetty.http.HttpVersion; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; + +/** Exercises an extension HTTP method through Jetty, Solr dispatch, and Jersey. */ +public class HttpQueryIntegrationTest extends SolrTestCase { + @ClassRule public static final SolrJettyTestRule solrTestRule = new SolrJettyTestRule(); + + @BeforeClass + public static void setupSolr() throws Exception { + final var solrHome = createTempDir(); + Files.copy(TEST_PATH().resolve("solr.xml"), solrHome.resolve("solr.xml")); + final var coreDir = solrHome.resolve("collection1"); + copyMinConf(coreDir, "name=collection1\n", "solrconfig-minimal.xml"); + final var config = coreDir.resolve("conf/solrconfig.xml"); + Files.writeString( + config, + Files.readString(config) + .replaceAll("]*/>", "") + .replace( + "", + "\n")); + solrTestRule.startSolr(solrHome); + } + + @Test + public void testQueryWithJsonBody() throws Exception { + for (var version : List.of(HttpVersion.HTTP_1_1, HttpVersion.HTTP_2)) { + try (var solrClient = + new HttpJettySolrClient.Builder(solrTestRule.getBaseUrl()) + .useHttp1_1(version == HttpVersion.HTTP_1_1) + .build()) { + final var response = + solrClient + .getHttpClient() + .newRequest(queryUrl()) + .version(version) + .method("QUERY") + .body( + new StringRequestContent( + "application/json", "{\"query\":\"id:42\"}", StandardCharsets.UTF_8)) + .send(); + assertEquals(response.getContentAsString(), 200, response.getStatus()); + assertEquals(version, response.getVersion()); + assertEquals( + "id:42", + ((Map) Utils.fromJSONString(response.getContentAsString())).get("query")); + } + } + } + + @Test + public void testQueryThroughSolrJ() throws Exception { + final var request = + new V2Request.Builder("/cores/collection1/query-spike") + .withMethod(SolrRequest.METHOD.QUERY) + .withPayload("{\"query\":\"id:42\"}") + .build(); + request.setResponseParser(new JsonMapResponseParser()); + assertEquals("id:42", solrTestRule.getJetty().getSolrClient().request(request).get("query")); + } + + @Test + public void testPostDoesNotInvokeQueryEndpoint() throws Exception { + final var response = + solrTestRule + .getJetty() + .getSolrClient() + .getHttpClient() + .newRequest(queryUrl()) + .method("POST") + .body( + new StringRequestContent( + "application/json", "{\"query\":\"id:42\"}", StandardCharsets.UTF_8)) + .send(); + assertEquals(response.getContentAsString(), 405, response.getStatus()); + } + + private String queryUrl() { + return solrTestRule.getJetty().getBaseURLV2() + "/cores/collection1/query-spike"; + } + + /** Registers the test resource with the core's Jersey application. */ + public static class QueryHandler extends RequestHandlerBase { + @Override + public PermissionNameProvider.Name getPermissionName(AuthorizationContext request) { + return PermissionNameProvider.Name.READ_PERM; + } + + @Override + public Boolean registerV2() { + return true; + } + + @Override + public Collection> getJerseyResources() { + return List.of(QueryResource.class); + } + + @Override + public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) {} + + @Override + public String getDescription() { + return "HTTP QUERY spike"; + } + } + + /** Test-only V2 endpoint that echoes the JSON query to verify entity handling. */ + @Path("/cores/{coreName}/query-spike") + public static class QueryResource extends JerseyResource { + @QUERY + @Consumes("application/json") + @Produces("application/json") + @PermissionName(PermissionNameProvider.Name.READ_PERM) + public Map query(Map body) { + return body; + } + } +} diff --git a/solr/solrj-jetty/src/java/org/apache/solr/client/solrj/jetty/HttpJettySolrClient.java b/solr/solrj-jetty/src/java/org/apache/solr/client/solrj/jetty/HttpJettySolrClient.java index cf05fdac07ec..6c7ea58eec4a 100644 --- a/solr/solrj-jetty/src/java/org/apache/solr/client/solrj/jetty/HttpJettySolrClient.java +++ b/solr/solrj-jetty/src/java/org/apache/solr/client/solrj/jetty/HttpJettySolrClient.java @@ -719,11 +719,11 @@ private MakeRequestReturnValue makeRequest( } if (SolrRequest.METHOD.POST == solrRequest.getMethod() - || SolrRequest.METHOD.PUT == solrRequest.getMethod()) { + || SolrRequest.METHOD.PUT == solrRequest.getMethod() + || SolrRequest.METHOD.QUERY == solrRequest.getMethod()) { RequestWriter.ContentWriter contentWriter = requestWriter.getContentWriter(solrRequest); - HttpMethod method = - SolrRequest.METHOD.POST == solrRequest.getMethod() ? HttpMethod.POST : HttpMethod.PUT; + String method = solrRequest.getMethod().toString(); if (contentWriter instanceof RequestWriter.MultipartContentWriter multipartWriter) { // send server list and request list as query string params diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java b/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java index 4e0429e124fd..56f552f192b9 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/SolrRequest.java @@ -63,6 +63,7 @@ public enum METHOD { HEAD, POST, PUT, + QUERY, DELETE; /** @@ -113,6 +114,7 @@ public enum SolrClientContext { METHOD.GET.toString(), METHOD.POST.toString(), METHOD.PUT.toString(), + METHOD.QUERY.toString(), METHOD.DELETE.toString()); private METHOD method = METHOD.GET; diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java index e6b80030677c..42a2c6130381 100644 --- a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiSqlScreenTest.java @@ -47,16 +47,85 @@ public static void setupCollection() throws Exception { @Test public void testSqlQueryViaUi() { + assertSqlQueryViaUi("POST", "SELECT id FROM " + COLLECTION + " LIMIT 10"); + } + + @Test + public void testSqlQueryViaQuery() { + assertSqlQueryViaUi("QUERY", "SELECT id FROM " + COLLECTION + " LIMIT 10"); + } + + @Test + public void testSqlQueryViaGet() { + assertSqlQueryViaUi("GET", "SELECT id FROM " + COLLECTION + " LIMIT 10"); + } + + @Test + public void testLargeSqlStatementViaPost() { + assertSqlQueryViaUi("POST", largeStatement()); + } + + @Test + public void testLargeSqlStatementViaQuery() { + assertSqlQueryViaUi("QUERY", largeStatement()); + } + + private String largeStatement() { + return "SELECT /* " + "a".repeat(20000) + " */ id FROM " + COLLECTION + " LIMIT 10"; + } + + private void assertSqlQueryViaUi(String method, String statement) { openPage(COLLECTION + "/sqlquery", By.id("sqlquery")); + WebElement selector = waitFor(By.id("httpMethod")); + assertEquals("POST", selector.getDomProperty("value")); + selector.findElement(By.cssSelector("option[value='" + method + "']")).click(); + ((JavascriptExecutor) driver) + .executeScript( + "window.sqlRequest = null;" + + "var originalOpen = XMLHttpRequest.prototype.open;" + + "var originalSend = XMLHttpRequest.prototype.send;" + + "XMLHttpRequest.prototype.open = function(method, url) {" + + " if (/\\/sql(?:\\?|$)/.test(url)) {" + + " this.sqlRequest = window.sqlRequest = {method: method, url: url};" + + " }" + + " return originalOpen.apply(this, arguments);" + + "};" + + "XMLHttpRequest.prototype.send = function(body) {" + + " if (this.sqlRequest) this.sqlRequest.body = body;" + + " return originalSend.apply(this, arguments);" + + "};"); WebElement stmt = waitFor(By.id("sqlexp")); - stmt.clear(); - stmt.sendKeys("SELECT id FROM " + COLLECTION + " LIMIT 10"); + ((JavascriptExecutor) driver) + .executeScript( + "arguments[0].value = arguments[1];" + + "arguments[0].dispatchEvent(new Event('input', {bubbles: true}));", + stmt, + statement); click(By.xpath("//div[@id='sqlquery']//button[@type='submit']")); // the result grid lists all documents for (int i = 1; i <= 3; i++) { waitForPageContains("sql-doc-" + i); } + assertEquals( + method, ((JavascriptExecutor) driver).executeScript("return window.sqlRequest.method;")); + if ("GET".equals(method)) { + assertNull(((JavascriptExecutor) driver).executeScript("return window.sqlRequest.body;")); + assertEquals( + statement, + ((JavascriptExecutor) driver) + .executeScript( + "return new URL(window.sqlRequest.url, window.location.href).searchParams.get('stmt');")); + } else { + assertEquals( + "stmt=" + statement, + ((JavascriptExecutor) driver) + .executeScript("return decodeURIComponent(window.sqlRequest.body);")); + assertEquals( + false, + ((JavascriptExecutor) driver) + .executeScript("return window.sqlRequest.url.includes('stmt=');")); + } assertNoSevereConsoleErrors(); } diff --git a/solr/webapp/web/js/angular/controllers/sqlquery.js b/solr/webapp/web/js/angular/controllers/sqlquery.js index 67d0f8cf7882..682876995f10 100644 --- a/solr/webapp/web/js/angular/controllers/sqlquery.js +++ b/solr/webapp/web/js/angular/controllers/sqlquery.js @@ -19,6 +19,7 @@ solrAdminApp.controller('SQLQueryController', $scope.resetMenu("sqlquery", Constants.IS_COLLECTION_PAGE); $scope.qt = "sql"; + $scope.httpMethod = "POST"; $scope.doExplanation = false $scope.gridOptions = { enableSorting: false, @@ -106,11 +107,18 @@ solrAdminApp.controller('SQLQueryController', $scope.url = Query.url(params); - Query.query(params, function(data) { + var onSuccess = function(data) { $scope.showResult(data.toJSON().data); - }, function(rejection) { + }; + var onError = function(rejection) { $scope.showResult((rejection.data && rejection.data.data) || ("HTTP " + rejection.status + " " + rejection.statusText)); - }); + }; + if ($scope.httpMethod === "GET") { + Query.query(params, onSuccess, onError); + } else { + var sendRequest = $scope.httpMethod === "QUERY" ? Query.queryQuery : Query.queryPost; + sendRequest({core: params.core, handler: params.handler}, params, onSuccess, onError); + } }; } ); diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index 462d684121e0..bae76d8612e0 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -355,25 +355,48 @@ solrAdminServices.factory('Metrics', }]) .factory('Query', ['$resource', function($resource) { - var resource = $resource(':core/:handler', {core: '@core', handler: '@handler', '_':Date.now()}, { - "query": { - method: "GET", - transformResponse: function (data) { - return {data: data} - }, - headers: {doNotIntercept: "true"} - } - }); - resource.url = function(params) { + var toQueryString = function(params) { var qs = []; - for (key in params) { + for (var key in params) { if (key != "core" && key != "handler") { for (var i in params[key]) { qs.push(key + "=" + encodeURIComponent(params[key][i])); } } } - return "" + params.core + "/" + params.handler + "?" + qs.sort().join("&"); + return qs.sort().join("&"); + } + var wrapRawResponse = function(data) { + return {data: data} + } + var resource = $resource(':core/:handler', {core: '@core', handler: '@handler', '_':Date.now()}, { + "query": { + method: "GET", + transformResponse: wrapRawResponse, + headers: {doNotIntercept: "true"} + }, + "queryPost": { + method: "POST", + transformRequest: toQueryString, + transformResponse: wrapRawResponse, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + doNotIntercept: "true" + } + }, + "queryQuery": { + method: "QUERY", + hasBody: true, + transformRequest: toQueryString, + transformResponse: wrapRawResponse, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + doNotIntercept: "true" + } + } + }); + resource.url = function(params) { + return "" + params.core + "/" + params.handler + "?" + toQueryString(params); } return resource; }]) diff --git a/solr/webapp/web/partials/sqlquery.html b/solr/webapp/web/partials/sqlquery.html index 76a2506c061b..4acce9674636 100644 --- a/solr/webapp/web/partials/sqlquery.html +++ b/solr/webapp/web/partials/sqlquery.html @@ -22,6 +22,12 @@ SQL Query Statement + + syntax help