From 809114f3ec5752677d678e020bd9705da2c4c614 Mon Sep 17 00:00:00 2001 From: Sai Swapnil Aremanda Date: Fri, 16 Jan 2026 09:42:19 -0800 Subject: [PATCH] Adds broker endpoint to validate queries without execution --- .../api/resources/PinotClientRequest.java | 90 +++++++++++++++++++ .../api/resources/PinotClientRequestTest.java | 31 +++++++ 2 files changed, 121 insertions(+) diff --git a/pinot-broker/src/main/java/org/apache/pinot/broker/api/resources/PinotClientRequest.java b/pinot-broker/src/main/java/org/apache/pinot/broker/api/resources/PinotClientRequest.java index fe9187ff373a..48271d6bdf66 100644 --- a/pinot-broker/src/main/java/org/apache/pinot/broker/api/resources/PinotClientRequest.java +++ b/pinot-broker/src/main/java/org/apache/pinot/broker/api/resources/PinotClientRequest.java @@ -40,6 +40,7 @@ import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; +import javax.annotation.Nullable; import javax.inject.Inject; import javax.inject.Named; import javax.ws.rs.DELETE; @@ -92,6 +93,7 @@ import org.apache.pinot.spi.utils.CommonConstants.Broker.Request; import org.apache.pinot.spi.utils.JsonUtils; import org.apache.pinot.sql.parsers.PinotSqlType; +import org.apache.pinot.sql.parsers.SqlCompilationException; import org.apache.pinot.sql.parsers.SqlNodeAndOptions; import org.apache.pinot.tsdb.spi.series.TimeSeriesBlock; import org.glassfish.jersey.server.ManagedAsync; @@ -246,6 +248,94 @@ public Response getQueryFingerprint(String query, } } + @POST + @Produces(MediaType.APPLICATION_JSON) + @Path("query/sql/validateSyntax") + @ApiOperation(value = "Validate SQL query syntax", + notes = "Validates if the SQL query can be parsed by Calcite without executing it. " + + "This is useful for validating if queries can be parsed successfully") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Validation response (check 'valid' field for result)") + }) + @ManualAuthorization + public QuerySyntaxValidationResponse validateSqlQuerySyntaxPost( + @ApiParam(value = "JSON with 'sql' field", required = true) String query, + @Context HttpHeaders httpHeaders) { + try { + JsonNode requestJson = JsonUtils.stringToJsonNode(query); + if (!requestJson.has(Request.SQL)) { + return new QuerySyntaxValidationResponse( + false, + "Payload is missing query string field 'sql'", + QueryErrorCode.JSON_PARSING + ); + } + String sqlQuery = requestJson.get(Request.SQL).asText(); + return performSqlSyntaxValidation(sqlQuery); + } catch (Exception e) { + LOGGER.error("Error parsing validation request", e); + return new QuerySyntaxValidationResponse( + false, + e.getMessage(), + QueryErrorCode.JSON_PARSING + ); + } + } + + private QuerySyntaxValidationResponse performSqlSyntaxValidation(String sqlQuery) { + try { + // This catches reserved keyword issues and syntax errors + SqlNodeAndOptions sqlNodeAndOptions = RequestUtils.parseQuery(sqlQuery); + + return new QuerySyntaxValidationResponse( + true, + null, + null + ); + } catch (SqlCompilationException e) { + LOGGER.debug("SQL validation failed for query: {}", sqlQuery, e); + return new QuerySyntaxValidationResponse( + false, + e.getMessage(), + QueryErrorCode.SQL_PARSING + ); + } catch (Exception e) { + String msg = "Unexpected error parsing query" + e.getMessage(); + LOGGER.error(msg); + return new QuerySyntaxValidationResponse( + false, + msg, + QueryErrorCode.UNKNOWN + ); + } + } + + public static class QuerySyntaxValidationResponse { + private final boolean _validQuerySyntax; + private final String _errorMessage; + private final QueryErrorCode _errorCode; + + public QuerySyntaxValidationResponse(boolean validQuery, String errorMessage, QueryErrorCode errorCode) { + _validQuerySyntax = validQuery; + _errorMessage = errorMessage; + _errorCode = errorCode; + } + + public boolean isValidQuerySyntax() { + return _validQuerySyntax; + } + + @Nullable + public String getErrorMessage() { + return _errorMessage; + } + + @Nullable + public QueryErrorCode getErrorCode() { + return _errorCode; + } + } + @GET @ManagedAsync @Produces(MediaType.APPLICATION_JSON) diff --git a/pinot-broker/src/test/java/org/apache/pinot/broker/api/resources/PinotClientRequestTest.java b/pinot-broker/src/test/java/org/apache/pinot/broker/api/resources/PinotClientRequestTest.java index 8317493bcd25..e26824b8c980 100644 --- a/pinot-broker/src/test/java/org/apache/pinot/broker/api/resources/PinotClientRequestTest.java +++ b/pinot-broker/src/test/java/org/apache/pinot/broker/api/resources/PinotClientRequestTest.java @@ -311,4 +311,35 @@ public void testQueryResponseSizeMetric() assertEquals(sizeCaptor.getValue().longValue(), expectedSize, "Metric should record the actual response size in bytes"); } + + @Test + public void testPinotQueryValidationWithValidQuery() throws Exception { + String validQuery = "{\"sql\":\"SELECT * FROM myTable LIMIT 10\"}"; + PinotClientRequest.QuerySyntaxValidationResponse response = + _pinotClientRequest.validateSqlQuerySyntaxPost(validQuery, _httpHeaders); + + Assert.assertTrue(response.isValidQuerySyntax(), "Response value should be valid"); + Assert.assertNull(response.getErrorMessage()); + Assert.assertNull(response.getErrorCode()); + } + + @Test + public void testPinotQueryValidationWithInvalidQuery() throws Exception { + String invalidQuery = "{\"sql\":\"SELECT select FROM myTable LIMIT 10\"}"; + + PinotClientRequest.QuerySyntaxValidationResponse response = + _pinotClientRequest.validateSqlQuerySyntaxPost(invalidQuery, _httpHeaders); + Assert.assertFalse(response.isValidQuerySyntax(), "Response value should be invalid"); + assertEquals(response.getErrorCode(), QueryErrorCode.SQL_PARSING); + } + + @Test + public void testPinotQueryValidationWithInvalidRequestPayload() throws Exception { + String invalidQuery = "{\"query\":\"SELECT select FROM myTable LIMIT 10\"}"; + + PinotClientRequest.QuerySyntaxValidationResponse response = + _pinotClientRequest.validateSqlQuerySyntaxPost(invalidQuery, _httpHeaders); + Assert.assertFalse(response.isValidQuerySyntax(), "Response value should be invalid"); + assertEquals(response.getErrorCode(), QueryErrorCode.JSON_PARSING); + } }