This is an automated email from the ASF dual-hosted git repository.
AlbericByte pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git
The following commit(s) were added to refs/heads/master by this push:
new 6c3e0a98e7a fix: Improve reserved keyword SQL parse errors (#19719)
6c3e0a98e7a is described below
commit 6c3e0a98e7a37b446e92d030f8e4bc7787818214
Author: Frank Chen <[email protected]>
AuthorDate: Tue Jul 28 23:11:26 2026 +0800
fix: Improve reserved keyword SQL parse errors (#19719)
* Improve reserved keyword SQL parse errors
* Gate reserved keyword parse hints
* Handle reserved keyword parse contexts
* Avoid keyword hints for function calls
* Avoid hints for expected keywords
---
.../druid/sql/calcite/parser/DruidSqlParser.java | 95 +++++++++++++++++++++-
.../sql/calcite/parser/DruidSqlParserTest.java | 50 ++++++++++++
2 files changed, 141 insertions(+), 4 deletions(-)
diff --git
a/sql/src/main/java/org/apache/druid/sql/calcite/parser/DruidSqlParser.java
b/sql/src/main/java/org/apache/druid/sql/calcite/parser/DruidSqlParser.java
index fe0f0a681c4..ab9e9e1878b 100644
--- a/sql/src/main/java/org/apache/druid/sql/calcite/parser/DruidSqlParser.java
+++ b/sql/src/main/java/org/apache/druid/sql/calcite/parser/DruidSqlParser.java
@@ -28,9 +28,11 @@ import org.apache.calcite.sql.SqlNodeList;
import org.apache.calcite.sql.SqlSetOption;
import org.apache.calcite.sql.SqlUtil;
import org.apache.calcite.sql.dialect.CalciteSqlDialect;
+import org.apache.calcite.sql.parser.SqlAbstractParserImpl;
import org.apache.calcite.sql.parser.SqlParseException;
import org.apache.calcite.sql.parser.SqlParser;
import org.apache.calcite.sql.parser.SqlParserPos;
+import org.apache.calcite.sql.parser.SqlParserUtil;
import org.apache.calcite.sql.type.SqlTypeName;
import org.apache.calcite.util.NlsString;
import org.apache.calcite.util.SourceStringReader;
@@ -47,6 +49,7 @@ import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
+import java.util.Locale;
import java.util.Map;
/**
@@ -73,13 +76,13 @@ public class DruidSqlParser
public static StatementAndSetContext parse(final String sql, final boolean
allowSetStatements)
{
+ final SqlParser parser = SqlParser.create(new SourceStringReader(sql),
PARSER_CONFIG);
try {
- SqlParser parser = SqlParser.create(new SourceStringReader(sql),
PARSER_CONFIG);
SqlNode sqlNode = parser.parseStmtList();
return processStatementList(sqlNode, allowSetStatements);
}
catch (SqlParseException e) {
- throw translateParseException(e);
+ throw translateParseException(e, parser.getMetadata(), sql);
}
}
@@ -176,7 +179,11 @@ public class DruidSqlParser
/**
* Constructs a user-friendly {@link DruidException} from a Calcite {@link
SqlParseException}.
*/
- private static DruidException translateParseException(SqlParseException e)
+ private static DruidException translateParseException(
+ SqlParseException e,
+ SqlAbstractParserImpl.Metadata parserMetadata,
+ String sql
+ )
{
final Throwable cause = e.getCause();
if (cause instanceof DruidException) {
@@ -198,9 +205,32 @@ public class DruidSqlParser
.withContext("sourceType", "sql");
} else {
final String theUnexpectedToken =
getUnexpectedTokenString(parseException);
-
+ final String firstUnexpectedToken =
getUnexpectedTokenString(parseException, 1);
final String[] tokenDictionary = e.getTokenImages();
final int[][] expectedTokenSequences = e.getExpectedTokenSequences();
+
+ if (parserMetadata != null
+ && isIdentifierExpected(tokenDictionary, expectedTokenSequences)
+ && !isKeywordExpected(firstUnexpectedToken, tokenDictionary,
expectedTokenSequences)
+ && !isFunctionCall(sql, failurePosition,
parseException.currentToken.next.image)
+ &&
parserMetadata.isReservedWord(firstUnexpectedToken.toUpperCase(Locale.ROOT))) {
+ return InvalidSqlInput
+ .exception(
+ e,
+ "Token [%s] (line [%s], column [%s]) is a reserved keyword. "
+ + "To use it as an identifier, quote it as [\"%s\"]",
+ firstUnexpectedToken,
+ failurePosition.getLineNum(),
+ failurePosition.getColumnNum(),
+ firstUnexpectedToken
+ )
+ .withContext("line", failurePosition.getLineNum())
+ .withContext("column", failurePosition.getColumnNum())
+ .withContext("endLine", failurePosition.getEndLineNum())
+ .withContext("endColumn", failurePosition.getEndColumnNum())
+ .withContext("token", firstUnexpectedToken);
+ }
+
final ArrayList<String> expectedTokens = new
ArrayList<>(expectedTokenSequences.length);
for (int[] expectedTokenSequence : expectedTokenSequences) {
String[] strings = new String[expectedTokenSequence.length];
@@ -232,6 +262,57 @@ public class DruidSqlParser
return InvalidSqlInput.exception(e.getMessage());
}
+ private static boolean isIdentifierExpected(String[] tokenDictionary,
int[][] expectedTokenSequences)
+ {
+ for (int[] expectedTokenSequence : expectedTokenSequences) {
+ if (expectedTokenSequence.length > 0) {
+ final String token = tokenDictionary[expectedTokenSequence[0]];
+ if ("<IDENTIFIER>".equals(token)
+ || "<QUOTED_IDENTIFIER>".equals(token)
+ || "<BACK_QUOTED_IDENTIFIER>".equals(token)
+ || "<BRACKET_QUOTED_IDENTIFIER>".equals(token)
+ || "<UNICODE_QUOTED_IDENTIFIER>".equals(token)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static boolean isKeywordExpected(
+ String unexpectedToken,
+ String[] tokenDictionary,
+ int[][] expectedTokenSequences
+ )
+ {
+ for (int[] expectedTokenSequence : expectedTokenSequences) {
+ if (expectedTokenSequence.length > 0
+ && unexpectedToken.equalsIgnoreCase(
+
SqlParserUtil.getTokenVal(tokenDictionary[expectedTokenSequence[0]])
+ )) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean isFunctionCall(String sql, SqlParserPos
failurePosition, String token)
+ {
+ int tokenEndOffset = 0;
+ for (int line = 1; line < failurePosition.getLineNum(); line++) {
+ tokenEndOffset = sql.indexOf('\n', tokenEndOffset) + 1;
+ if (tokenEndOffset == 0) {
+ return false;
+ }
+ }
+
+ tokenEndOffset += failurePosition.getColumnNum() - 1 + token.length();
+ while (tokenEndOffset < sql.length() &&
Character.isWhitespace(sql.charAt(tokenEndOffset))) {
+ tokenEndOffset++;
+ }
+ return tokenEndOffset < sql.length() && sql.charAt(tokenEndOffset) == '(';
+ }
+
/**
* Grabs the unexpected token string. This code is borrowed with minimal
adjustments from
* {@link ParseException#getMessage()}. It is possible that if that code
changes, we need to also
@@ -250,6 +331,12 @@ public class DruidSqlParser
}
}
+ return getUnexpectedTokenString(parseException, maxSize);
+ }
+
+ private static String getUnexpectedTokenString(ParseException
parseException, int maxSize)
+ {
+
StringBuilder bob = new StringBuilder();
Token tok = parseException.currentToken.next;
for (int i = 0; i < maxSize; i++) {
diff --git
a/sql/src/test/java/org/apache/druid/sql/calcite/parser/DruidSqlParserTest.java
b/sql/src/test/java/org/apache/druid/sql/calcite/parser/DruidSqlParserTest.java
index 1765ecc2f5c..651b8f712f7 100644
---
a/sql/src/test/java/org/apache/druid/sql/calcite/parser/DruidSqlParserTest.java
+++
b/sql/src/test/java/org/apache/druid/sql/calcite/parser/DruidSqlParserTest.java
@@ -140,4 +140,54 @@ public class DruidSqlParserTest
);
Assert.assertTrue(exception.getMessage().contains("Unsupported type for
SET"));
}
+
+ @Test
+ public void testParse_reservedKeywordIdentifier()
+ {
+ final DruidException exception = Assert.assertThrows(
+ DruidException.class,
+ () -> DruidSqlParser.parse("SELECT start FROM sys.\"segments\" LIMIT
1", false)
+ );
+
+ Assert.assertEquals(
+ "Token [start] (line [1], column [8]) is a reserved keyword. "
+ + "To use it as an identifier, quote it as [\"start\"]",
+ exception.getMessage()
+ );
+ }
+
+ @Test
+ public void testParse_reservedKeywordOutsideIdentifierContext()
+ {
+ final DruidException exception = Assert.assertThrows(
+ DruidException.class,
+ () -> DruidSqlParser.parse("SELECT * FROM foo GROUP ORDER BY x", false)
+ );
+
+ Assert.assertFalse(exception.getMessage().contains("is a reserved
keyword"));
+ }
+
+ @Test
+ public void testParse_expectedReservedKeyword()
+ {
+ final DruidException exception = Assert.assertThrows(
+ DruidException.class,
+ () -> DruidSqlParser.parse("SELECT a FROM", false)
+ );
+
+ Assert.assertFalse(exception.getMessage().contains("is a reserved
keyword"));
+ }
+
+ @Test
+ public void testParse_reservedKeywordFunctionCall()
+ {
+ // UNNEST is a reserved keyword, but it appears in a function-call
context, not as an identifier.
+ final DruidException exception = Assert.assertThrows(
+ DruidException.class,
+ () -> DruidSqlParser.parse("SELECT strlen(unnest(a_int))", false)
+ );
+
+ Assert.assertTrue(exception.getMessage().contains("Received an unexpected
token"));
+ Assert.assertFalse(exception.getMessage().contains("is a reserved
keyword"));
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]