Skip to content

Commit eaddc96

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

File tree

6 files changed

+172
-35
lines changed

6 files changed

+172
-35
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.
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_EXPLAIN_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/QueryResultView.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ public abstract class QueryResultView implements View {
4040
*/
4141
public static final String QUERY_RESULT_KEY = "queryResult";
4242

43+
/**
44+
* Key by which the query result explanation is stored in the model.
45+
*/
46+
public static final String QUERY_EXPLAIN_RESULT_KEY = "explainResult";
47+
4348
/**
4449
* Key by which the query result writer factory is stored in the model.
4550
*/

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

Lines changed: 38 additions & 8 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,14 @@ public ModelAndView handleQueryRequest(HttpServletRequest request, RequestMethod
134141

135142
}
136143

144+
protected Explanation explainQuery(final Query query, final Explanation.Level explainLevel)
145+
throws ServerHTTPException {
146+
throw new ServerHTTPException("unimplemented explainQuery feature");
147+
}
148+
149+
protected abstract ModelAndView getExplainQueryResponse(
150+
final HttpServletRequest request, final HttpServletResponse response, final Explanation explanation);
151+
137152
abstract protected Object evaluateQuery(Query query, long limit, long offset, boolean distinct)
138153
throws ClientHTTPException;
139154

@@ -147,9 +162,11 @@ abstract protected String getQueryString(HttpServletRequest request, RequestMeth
147162
abstract protected Query getQuery(HttpServletRequest request, RepositoryConnection repositoryCon,
148163
String queryString) throws IOException, HTTPException;
149164

150-
protected ModelAndView getModelAndView(HttpServletRequest request, HttpServletResponse response,
165+
protected ModelAndView getModelAndView(
166+
HttpServletRequest request, HttpServletResponse response,
151167
boolean headersOnly, RepositoryConnection repositoryCon, View view, Object queryResult,
152-
FileFormatServiceRegistry<? extends FileFormat, ?> registry) throws ClientHTTPException {
168+
FileFormatServiceRegistry<? extends FileFormat, ?> registry
169+
) throws ClientHTTPException {
153170
Map<String, Object> model = new HashMap<>();
154171
model.put(QueryResultView.FILENAME_HINT_KEY, "query-result");
155172
model.put(QueryResultView.QUERY_RESULT_KEY, queryResult);
@@ -172,6 +189,19 @@ protected long getLimit(HttpServletRequest request) throws ClientHTTPException {
172189
return getParam(request, Protocol.LIMIT_PARAM_NAME, 0L, Long.TYPE);
173190
}
174191

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

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,11 @@
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;
3134
import org.apache.http.HttpStatus;
@@ -38,9 +41,7 @@
3841
import org.eclipse.rdf4j.http.server.ClientHTTPException;
3942
import org.eclipse.rdf4j.http.server.HTTPException;
4043
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;
44+
import org.eclipse.rdf4j.http.server.repository.*;
4445
import org.eclipse.rdf4j.http.server.repository.resolver.RepositoryResolver;
4546
import org.eclipse.rdf4j.model.IRI;
4647
import org.eclipse.rdf4j.model.Value;
@@ -56,6 +57,7 @@
5657
import org.eclipse.rdf4j.query.TupleQuery;
5758
import org.eclipse.rdf4j.query.TupleQueryResult;
5859
import org.eclipse.rdf4j.query.UnsupportedQueryLanguageException;
60+
import org.eclipse.rdf4j.query.explanation.Explanation;
5961
import org.eclipse.rdf4j.query.impl.SimpleDataset;
6062
import org.eclipse.rdf4j.query.resultio.BooleanQueryResultWriterRegistry;
6163
import org.eclipse.rdf4j.query.resultio.TupleQueryResultWriterRegistry;
@@ -65,6 +67,7 @@
6567
import org.slf4j.Logger;
6668
import org.slf4j.LoggerFactory;
6769
import org.springframework.web.bind.annotation.RequestMethod;
70+
import org.springframework.web.servlet.ModelAndView;
6871
import org.springframework.web.servlet.View;
6972

7073
public class DefaultQueryRequestHandler extends AbstractQueryRequestHandler {
@@ -75,6 +78,22 @@ public DefaultQueryRequestHandler(RepositoryResolver repositoryResolver) {
7578
super(repositoryResolver);
7679
}
7780

81+
@Override
82+
protected Explanation explainQuery(final Query query, final Explanation.Level level) {
83+
return query.explain(level);
84+
}
85+
86+
@Override
87+
protected ModelAndView getExplainQueryResponse(
88+
final HttpServletRequest request, final HttpServletResponse response,
89+
final Explanation explanation
90+
) {
91+
Map<String, Object> model = new HashMap<>();
92+
model.put(QueryResultView.FILENAME_HINT_KEY, "query-result");
93+
model.put(QueryResultView.QUERY_EXPLAIN_RESULT_KEY, explanation);
94+
return new ModelAndView(new ExplainQueryResultView(), model);
95+
}
96+
7897
@Override
7998
protected Object evaluateQuery(Query query, long limit, long offset, boolean distinct) throws ClientHTTPException {
8099
if (query instanceof TupleQuery) {

0 commit comments

Comments
 (0)