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]

Reply via email to