Skip to content

Commit 1ab08af

Browse files
committed
GH-2979 Allow query explanation via HTTPRepository / REST API
1 parent 1e175b6 commit 1ab08af

File tree

5 files changed

+182
-43
lines changed

5 files changed

+182
-43
lines changed

core/http/protocol/src/main/java/org/eclipse/rdf4j/http/protocol/Protocol.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ public enum TIMEOUT {
166166

167167
public static final String OFFSET_PARAM_NAME = "offset";
168168

169+
public static final String EXPLAIN_PARAM_NAME = "explain";
170+
169171
/**
170172
* Parameter name for the query language parameter.
171173
*/

site/static/documentation/reference/rest-api/rdf4j-openapi.yaml

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ components:
187187
schema:
188188
type: string
189189
format: binary
190+
190191
examples:
191192
SparqlXmlBindings:
192193
value: |
@@ -381,6 +382,22 @@ paths:
381382
description: Specifies the number of query solutions to skip. The value should be a positive integer. This parameter is cumulative with any OFFSET modifier in the supplied SPARQL query itself.
382383
schema:
383384
type: integer
385+
- name: explain
386+
in: query
387+
required: false
388+
description: |
389+
The level of explanation to return. If specified, the server will return an explanation of the query execution plan. The value should be one of the following:
390+
- `Unoptimized`: Simple parsed plan
391+
- `Optimized`: Parsed and optimized plan, including cost estimation
392+
- `Executed`: Plan as it was executed, including actual result size
393+
- `Timed`: Executed plan with timing details for each node
394+
schema:
395+
type: string
396+
enum:
397+
- Unoptimized
398+
- Optimized
399+
- Executed
400+
- Timed
384401
responses:
385402
'200':
386403
$ref: "#/components/responses/200SparqlResult"
@@ -449,7 +466,7 @@ paths:
449466
required: true
450467
$ref: "#/components/requestBodies/RdfData"
451468
responses:
452-
'204':
469+
'204':
453470
description: created
454471

455472
/repositories/{repositoryID}/statements:
@@ -829,8 +846,8 @@ paths:
829846
type: string
830847
example: http://www.example.com
831848
responses:
832-
204:
833-
description: The defined namespace was successfully set to the given prefix.
849+
204:
850+
description: The defined namespace was successfully set to the given prefix.
834851
delete:
835852
tags:
836853
- Namespaces
@@ -1126,24 +1143,24 @@ paths:
11261143
description: No content
11271144

11281145
delete:
1129-
tags:
1130-
- Transactions
1131-
summary: Abort a transaction
1132-
description: |
1133-
An active transaction can be aborted by means of a HTTP DELETE request on the transaction resource. This will execute a transaction rollback on the repository and will close the transaction. After executing a DELETE, further operations on the same transaction will result in an error.
1134-
parameters:
1135-
- name: repositoryID
1136-
in: path
1137-
required: true
1138-
description: The repository ID
1139-
schema:
1140-
type: string
1141-
- name: transactionID
1142-
in: path
1143-
required: true
1144-
schema:
1145-
type: string
1146-
description: The transaction ID
1147-
responses:
1148-
204:
1149-
description: Successfully aborted the defined transaction.
1146+
tags:
1147+
- Transactions
1148+
summary: Abort a transaction
1149+
description: |
1150+
An active transaction can be aborted by means of a HTTP DELETE request on the transaction resource. This will execute a transaction rollback on the repository and will close the transaction. After executing a DELETE, further operations on the same transaction will result in an error.
1151+
parameters:
1152+
- name: repositoryID
1153+
in: path
1154+
required: true
1155+
description: The repository ID
1156+
schema:
1157+
type: string
1158+
- name: transactionID
1159+
in: path
1160+
required: true
1161+
schema:
1162+
type: string
1163+
description: The transaction ID
1164+
responses:
1165+
204:
1166+
description: Successfully aborted the defined transaction.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2025 Eclipse RDF4J contributors, Chengxu Bian, and others.
3+
*
4+
* All rights reserved. This program and the accompanying materials
5+
* are made available under the terms of the Eclipse Distribution License v1.0
6+
* which accompanies this distribution, and is available at
7+
* http://www.eclipse.org/org/documents/edl-v10.php.
8+
*
9+
* SPDX-License-Identifier: BSD-3-Clause
10+
*******************************************************************************/
11+
package org.eclipse.rdf4j.http.server.repository;
12+
13+
import java.io.IOException;
14+
import java.io.PrintWriter;
15+
import java.util.Map;
16+
17+
import javax.servlet.http.HttpServletRequest;
18+
import javax.servlet.http.HttpServletResponse;
19+
20+
import org.apache.http.HttpStatus;
21+
import org.eclipse.rdf4j.http.protocol.Protocol;
22+
import org.eclipse.rdf4j.query.explanation.Explanation;
23+
24+
public class ExplainQueryResultView extends QueryResultView {
25+
26+
private static final String MIME_PLAIN = "text/plain";
27+
private static final String MIME_JSON = "application/json";
28+
29+
@Override
30+
protected void renderInternal(
31+
final Map model, final HttpServletRequest request, final HttpServletResponse response) throws IOException {
32+
33+
String mimeType = getRequestedMimeType(request);
34+
Explanation explanation = (Explanation) model.get(QUERY_RESULT_KEY);
35+
36+
if (explanation == null) {
37+
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "No explanation result found.");
38+
return;
39+
}
40+
41+
response.setCharacterEncoding("UTF-8");
42+
response.setStatus(HttpStatus.SC_OK);
43+
44+
try (PrintWriter writer = response.getWriter()) {
45+
if (MIME_JSON.equals(mimeType)) {
46+
response.setContentType(MIME_JSON);
47+
writer.write(explanation.toJson());
48+
} else if (MIME_PLAIN.equals(mimeType) || mimeType == null || mimeType.isEmpty()) {
49+
response.setContentType(MIME_PLAIN);
50+
writer.write(explanation.toString());
51+
} else {
52+
response.sendError(
53+
HttpServletResponse.SC_BAD_REQUEST,
54+
"Unsupported MIME type: " + mimeType + ". Must be either text/plain or application/json."
55+
);
56+
}
57+
}
58+
}
59+
60+
private String getRequestedMimeType(HttpServletRequest request) {
61+
String mimeType = request.getParameter(Protocol.ACCEPT_PARAM_NAME);
62+
return (mimeType != null) ? mimeType : request.getHeader("Accept");
63+
}
64+
}

tools/server-spring/src/main/java/org/eclipse/rdf4j/http/server/repository/handler/AbstractQueryRequestHandler.java

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import java.io.IOException;
1717
import java.util.HashMap;
1818
import java.util.Map;
19+
import java.util.Optional;
1920

2021
import javax.servlet.http.HttpServletRequest;
2122
import javax.servlet.http.HttpServletResponse;
@@ -32,6 +33,7 @@
3233
import org.eclipse.rdf4j.query.Query;
3334
import org.eclipse.rdf4j.query.QueryEvaluationException;
3435
import org.eclipse.rdf4j.query.QueryInterruptedException;
36+
import org.eclipse.rdf4j.query.explanation.Explanation;
3537
import org.eclipse.rdf4j.repository.Repository;
3638
import org.eclipse.rdf4j.repository.RepositoryConnection;
3739
import org.slf4j.Logger;
@@ -54,8 +56,10 @@ public AbstractQueryRequestHandler(RepositoryResolver repositoryResolver) {
5456
}
5557

5658
@Override
57-
public ModelAndView handleQueryRequest(HttpServletRequest request, RequestMethod requestMethod,
58-
HttpServletResponse response) throws HTTPException, IOException {
59+
public ModelAndView handleQueryRequest(
60+
HttpServletRequest request, RequestMethod requestMethod,
61+
HttpServletResponse response
62+
) throws HTTPException, IOException {
5963

6064
RepositoryConnection repositoryCon = null;
6165
Object queryResponse = null;
@@ -74,11 +78,14 @@ public ModelAndView handleQueryRequest(HttpServletRequest request, RequestMethod
7478
long limit = getLimit(request);
7579
long offset = getOffset(request);
7680
boolean distinct = isDistinct(request);
77-
81+
final Optional<Explanation.Level> explainLevel = getExplain(request);
7882
try {
79-
if (headersOnly) {
80-
queryResponse = null;
81-
} else {
83+
if (!headersOnly) {
84+
// explain param is present, return the query explanation
85+
if (explainLevel.isPresent()) {
86+
final Explanation explanation = explainQuery(query, explainLevel.get());
87+
return getExplainQueryResponse(request, response, explanation);
88+
}
8289
queryResponse = evaluateQuery(query, limit, offset, distinct);
8390
}
8491

@@ -134,6 +141,11 @@ public ModelAndView handleQueryRequest(HttpServletRequest request, RequestMethod
134141

135142
}
136143

144+
protected abstract Explanation explainQuery(final Query query, final Explanation.Level explainLevel);
145+
146+
protected abstract ModelAndView getExplainQueryResponse(
147+
final HttpServletRequest request, final HttpServletResponse response, final Explanation explanation);
148+
137149
abstract protected Object evaluateQuery(Query query, long limit, long offset, boolean distinct)
138150
throws ClientHTTPException;
139151

@@ -144,12 +156,16 @@ abstract protected Object evaluateQuery(Query query, long limit, long offset, bo
144156
abstract protected String getQueryString(HttpServletRequest request, RequestMethod requestMethod)
145157
throws HTTPException;
146158

147-
abstract protected Query getQuery(HttpServletRequest request, RepositoryConnection repositoryCon,
148-
String queryString) throws IOException, HTTPException;
159+
protected abstract Query getQuery(
160+
HttpServletRequest request, RepositoryConnection repositoryCon,
161+
String queryString
162+
) throws IOException, HTTPException;
149163

150-
protected ModelAndView getModelAndView(HttpServletRequest request, HttpServletResponse response,
164+
protected ModelAndView getModelAndView(
165+
HttpServletRequest request, HttpServletResponse response,
151166
boolean headersOnly, RepositoryConnection repositoryCon, View view, Object queryResult,
152-
FileFormatServiceRegistry<? extends FileFormat, ?> registry) throws ClientHTTPException {
167+
FileFormatServiceRegistry<? extends FileFormat, ?> registry
168+
) throws ClientHTTPException {
153169
Map<String, Object> model = new HashMap<>();
154170
model.put(QueryResultView.FILENAME_HINT_KEY, "query-result");
155171
model.put(QueryResultView.QUERY_RESULT_KEY, queryResult);
@@ -172,6 +188,19 @@ protected long getLimit(HttpServletRequest request) throws ClientHTTPException {
172188
return getParam(request, Protocol.LIMIT_PARAM_NAME, 0L, Long.TYPE);
173189
}
174190

191+
protected Optional<Explanation.Level> getExplain(HttpServletRequest request) throws ClientHTTPException {
192+
final String explainString = request.getParameter(Protocol.EXPLAIN_PARAM_NAME);
193+
if (explainString == null) {
194+
return Optional.empty();
195+
}
196+
try {
197+
final Explanation.Level level = Explanation.Level.valueOf(explainString);
198+
return Optional.of(level);
199+
} catch (final IllegalArgumentException e) {
200+
throw new ClientHTTPException("Invalid explanation level: " + explainString, e);
201+
}
202+
}
203+
175204
<T> T getParam(HttpServletRequest request, String distinctParamName, T defaultValue, Class<T> clazz)
176205
throws ClientHTTPException {
177206
if (clazz == Boolean.TYPE) {

tools/server-spring/src/main/java/org/eclipse/rdf4j/http/server/repository/handler/DefaultQueryRequestHandler.java

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,14 @@
2424

2525
import java.io.IOException;
2626
import java.util.Enumeration;
27+
import java.util.HashMap;
28+
import java.util.Map;
2729

2830
import javax.servlet.http.HttpServletRequest;
31+
import javax.servlet.http.HttpServletResponse;
2932

3033
import org.apache.commons.io.IOUtils;
34+
import org.apache.commons.math3.analysis.function.Exp;
3135
import org.apache.http.HttpStatus;
3236
import org.eclipse.rdf4j.common.lang.FileFormat;
3337
import org.eclipse.rdf4j.common.lang.service.FileFormatServiceRegistry;
@@ -38,9 +42,7 @@
3842
import org.eclipse.rdf4j.http.server.ClientHTTPException;
3943
import org.eclipse.rdf4j.http.server.HTTPException;
4044
import org.eclipse.rdf4j.http.server.ProtocolUtil;
41-
import org.eclipse.rdf4j.http.server.repository.BooleanQueryResultView;
42-
import org.eclipse.rdf4j.http.server.repository.GraphQueryResultView;
43-
import org.eclipse.rdf4j.http.server.repository.TupleQueryResultView;
45+
import org.eclipse.rdf4j.http.server.repository.*;
4446
import org.eclipse.rdf4j.http.server.repository.resolver.RepositoryResolver;
4547
import org.eclipse.rdf4j.model.IRI;
4648
import org.eclipse.rdf4j.model.Value;
@@ -56,6 +58,7 @@
5658
import org.eclipse.rdf4j.query.TupleQuery;
5759
import org.eclipse.rdf4j.query.TupleQueryResult;
5860
import org.eclipse.rdf4j.query.UnsupportedQueryLanguageException;
61+
import org.eclipse.rdf4j.query.explanation.Explanation;
5962
import org.eclipse.rdf4j.query.impl.SimpleDataset;
6063
import org.eclipse.rdf4j.query.resultio.BooleanQueryResultWriterRegistry;
6164
import org.eclipse.rdf4j.query.resultio.TupleQueryResultWriterRegistry;
@@ -65,7 +68,9 @@
6568
import org.slf4j.Logger;
6669
import org.slf4j.LoggerFactory;
6770
import org.springframework.web.bind.annotation.RequestMethod;
71+
import org.springframework.web.servlet.ModelAndView;
6872
import org.springframework.web.servlet.View;
73+
import org.springframework.web.servlet.view.json.MappingJackson2JsonView;
6974

7075
public class DefaultQueryRequestHandler extends AbstractQueryRequestHandler {
7176

@@ -75,6 +80,22 @@ public DefaultQueryRequestHandler(RepositoryResolver repositoryResolver) {
7580
super(repositoryResolver);
7681
}
7782

83+
@Override
84+
protected Explanation explainQuery(final Query query, final Explanation.Level level) {
85+
return query.explain(level);
86+
}
87+
88+
@Override
89+
protected ModelAndView getExplainQueryResponse(
90+
final HttpServletRequest request, final HttpServletResponse response,
91+
final Explanation explanation
92+
) {
93+
Map<String, Object> model = new HashMap<>();
94+
model.put(QueryResultView.FILENAME_HINT_KEY, "query-result");
95+
model.put(QueryResultView.QUERY_RESULT_KEY, explanation);
96+
return new ModelAndView(new ExplainQueryResultView(), model);
97+
}
98+
7899
@Override
79100
protected Object evaluateQuery(Query query, long limit, long offset, boolean distinct) throws ClientHTTPException {
80101
if (query instanceof TupleQuery) {
@@ -84,8 +105,10 @@ protected Object evaluateQuery(Query query, long limit, long offset, boolean dis
84105
} else if (query instanceof BooleanQuery) {
85106
return evaluateQuery((BooleanQuery) query, limit, offset, distinct);
86107
} else {
87-
throw new ClientHTTPException(SC_BAD_REQUEST,
88-
"Unsupported query type: " + query.getClass().getName());
108+
throw new ClientHTTPException(
109+
SC_BAD_REQUEST,
110+
"Unsupported query type: " + query.getClass().getName()
111+
);
89112
}
90113
}
91114

@@ -164,8 +187,10 @@ protected String getQueryString(HttpServletRequest request, RequestMethod reques
164187
}
165188

166189
@Override
167-
protected Query getQuery(HttpServletRequest request,
168-
RepositoryConnection repositoryCon, String queryString) throws IOException, HTTPException {
190+
protected Query getQuery(
191+
HttpServletRequest request,
192+
RepositoryConnection repositoryCon, String queryString
193+
) throws IOException, HTTPException {
169194

170195
QueryLanguage queryLn = getQueryLanguage(request.getParameter(QUERY_LANGUAGE_PARAM_NAME));
171196
String baseIRI = request.getParameter(Protocol.BASEURI_PARAM_NAME);
@@ -213,8 +238,10 @@ protected void setQueryParameters(HttpServletRequest request, RepositoryConnecti
213238

214239
if (parameterName.startsWith(BINDING_PREFIX) && parameterName.length() > BINDING_PREFIX.length()) {
215240
String bindingName = parameterName.substring(BINDING_PREFIX.length());
216-
Value bindingValue = ProtocolUtil.parseValueParam(request, parameterName,
217-
repositoryCon.getValueFactory());
241+
Value bindingValue = ProtocolUtil.parseValueParam(
242+
request, parameterName,
243+
repositoryCon.getValueFactory()
244+
);
218245
query.setBinding(bindingName, bindingValue);
219246
}
220247
}

0 commit comments

Comments
 (0)