Skip to content

Commit 9fc1980

Browse files
fix: allow keywords after a dot in Snowflake JSON paths and link their AST nodes
The previous commit lets a keyword name the first element after ":" in a JSON path. A keyword further along the path still threw a ParseException: SELECT v:a.output, v:a.b.from::INT, v:a[0].from.x[1] FROM t The expression parser reads v:a.output as a qualified column name, and a qualified name does not accept a keyword as a part. JsonPathElement now also looks ahead with isJsonPathWithKeywordAfterDot(), which walks the identifiers, dots and [...] subscripts of the path and reports whether a keyword follows a ".". If so, JsonPathKeyword parses the path, with a new JsonPathStep production taking a keyword after "." as an element name. isKeywordJsonPathName() shares its keyword test with it through isKeywordWord(). A word followed by "(" stays a function call, and t.output outside a JSON path is still rejected. JsonPathKeyword also left the last step or cast of a path without an AST node, for example the cast in v:select::INT or v:from::INT + 1. It linked the previous element before building each step, so the one built last was never linked. ASTNodeCoverageTest did not notice, as it only walks the SQL resources. Each step and cast is now linked where it is built, and the keyword-path cases are added to testStatementsTheCorpusLacks. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent e125308 commit 9fc1980

3 files changed

Lines changed: 114 additions & 9 deletions

File tree

‎src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt‎

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,14 +1093,21 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
10931093
*/
10941094
private boolean isKeywordJsonPathName() {
10951095
Token colon = getToken(0);
1096-
Token word = getToken(1);
1097-
if (colon == null || !":".equals(colon.image) || word.image == null
1098-
|| word.kind == S_IDENTIFIER || word.kind == S_QUOTED_IDENTIFIER
1099-
|| "(".equals(getToken(2).image)) {
1096+
return colon != null && ":".equals(colon.image) && isKeywordWord(1);
1097+
}
1098+
1099+
/**
1100+
* Whether the token at the given position is a word that is no identifier token, i.e. a keyword,
1101+
* and not the name of a function call.
1102+
*/
1103+
private boolean isKeywordWord(int position) {
1104+
Token word = getToken(position);
1105+
if (word.image == null || word.kind == S_IDENTIFIER || word.kind == S_QUOTED_IDENTIFIER
1106+
|| "(".equals(getToken(position + 1).image)) {
11001107
return false;
11011108
}
11021109
String image = word.image;
1103-
if (!Character.isLetter(image.charAt(0)) && image.charAt(0) != '_') {
1110+
if (image.isEmpty() || (!Character.isLetter(image.charAt(0)) && image.charAt(0) != '_')) {
11041111
return false;
11051112
}
11061113
for (int i = 1; i < image.length(); i++) {
@@ -1111,6 +1118,51 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
11111118
return true;
11121119
}
11131120

1121+
/**
1122+
* Whether the path after the ":" of a JSON path, which starts with an identifier, goes on with a
1123+
* keyword after a ".", e.g. v:a.output or v:a[0].b.from. The expression parser would read such a
1124+
* path as a qualified column name, which does not take a keyword as a part.
1125+
*/
1126+
private boolean isJsonPathWithKeywordAfterDot() {
1127+
Token colon = getToken(0);
1128+
if (colon == null || !":".equals(colon.image)) {
1129+
return false;
1130+
}
1131+
Token first = getToken(1);
1132+
if (first.kind != S_IDENTIFIER && first.kind != S_QUOTED_IDENTIFIER) {
1133+
return false;
1134+
}
1135+
int position = 2;
1136+
while (true) {
1137+
Token token = getToken(position);
1138+
if (".".equals(token.image)) {
1139+
if (isKeywordWord(position + 1)) {
1140+
return true;
1141+
}
1142+
Token name = getToken(position + 1);
1143+
if (name.kind != S_IDENTIFIER && name.kind != S_QUOTED_IDENTIFIER) {
1144+
return false;
1145+
}
1146+
position += 2;
1147+
} else if ("[".equals(token.image)) {
1148+
int depth = 0;
1149+
do {
1150+
token = getToken(position);
1151+
if ("[".equals(token.image)) {
1152+
depth++;
1153+
} else if ("]".equals(token.image)) {
1154+
depth--;
1155+
} else if (token.kind == EOF) {
1156+
return false;
1157+
}
1158+
position++;
1159+
} while (depth > 0);
1160+
} else {
1161+
return false;
1162+
}
1163+
}
1164+
}
1165+
11141166
private boolean isPostgreSqlJsonAccess(Token token) {
11151167
return ("->".equals(token.image) || "->>".equals(token.image)
11161168
|| "#>".equals(token.image) || "#>>".equals(token.image))
@@ -14976,7 +15028,7 @@ Expression JsonPathElement() #void:
1497615028
{ Expression element; }
1497715029
{
1497815030
(
14979-
LOOKAHEAD({ isKeywordJsonPathName() }) element=JsonPathKeyword()
15031+
LOOKAHEAD({ isKeywordJsonPathName() || isJsonPathWithKeywordAfterDot() }) element=JsonPathKeyword()
1498015032
|
1498115033
LOOKAHEAD({getAsBoolean(Feature.allowComplexParsing) && !interrupted}) element=Expression()
1498215034
|
@@ -15004,14 +15056,14 @@ Expression JsonPathKeyword() #void:
1500415056
keyword = getNextToken();
1500515057
element = linkToLastToken(new Column(keyword.image));
1500615058
}
15007-
( LOOKAHEAD(2) { linkASTIfMissing(element, first); } step=NavigationStep(element) { element = step; } )*
15059+
( LOOKAHEAD(2) step=JsonPathStep(element) { element = linked(step, first); } )*
1500815060
(
15009-
LOOKAHEAD(2) { linkASTIfMissing(element, first); } "::" type=ColDataType() {
15061+
LOOKAHEAD(2) "::" type=ColDataType() {
1501015062
cast = new CastExpression();
1501115063
cast.setUseCastKeyword(false);
1501215064
cast.setLeftExpression(element);
1501315065
cast.setColDataType(type);
15014-
element = cast;
15066+
element = linked(cast, first);
1501515067
}
1501615068
)*
1501715069
{
@@ -15020,6 +15072,23 @@ Expression JsonPathKeyword() #void:
1502015072
}
1502115073
}
1502215074

15075+
/**
15076+
* A navigation step within a JSON path, where a keyword after a "." names a path element as well:
15077+
* v:a.output, v:a[0].from.
15078+
*/
15079+
Expression JsonPathStep(Expression base) #void:
15080+
{ Expression step; }
15081+
{
15082+
(
15083+
LOOKAHEAD({ ".".equals(getToken(1).image) && isKeywordWord(2) }) "." {
15084+
step = new RowGetExpression(base, getNextToken().image);
15085+
}
15086+
|
15087+
step=NavigationStep(base)
15088+
)
15089+
{ return step; }
15090+
}
15091+
1502315092
JsonExpression JsonExpression(Expression expr, List<Map.Entry<Expression, String>> idents) : {
1502415093
JsonExpression result = new JsonExpression(expr, idents);
1502515094
Expression expression;

‎src/test/java/net/sf/jsqlparser/expression/JsonExpressionTest.java‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,36 @@ void testKeywordPathNameInsideNestedConstructs(String sql) throws JSQLParserExce
112112
assertSqlCanBeParsedAndDeparsed(sql, true, parser -> parser.withDialect(Dialect.SNOWFLAKE));
113113
}
114114

115+
@ParameterizedTest
116+
@ValueSource(strings = {"select", "from", "where", "join", "union", "and", "or", "not", "case",
117+
"when", "else", "as", "is", "like", "having", "distinct", "inner", "into", "with",
118+
"outer", "cross", "using", "output", "null", "true", "false", "order", "group"})
119+
void testSnowflakeKeywordAfterDotInPath(String keyword) throws JSQLParserException {
120+
String sqlStr = "SELECT v:a." + keyword + ", v:a.b." + keyword + "::INT, v:a[0]." + keyword
121+
+ ".x[1] FROM vartab";
122+
PlainSelect st = (PlainSelect) assertSqlCanBeParsedAndDeparsed(sqlStr, true);
123+
for (int i = 0; i < 3; i++) {
124+
Assertions.assertInstanceOf(JsonExpression.class, st.getSelectItem(i).getExpression());
125+
}
126+
}
127+
128+
@ParameterizedTest
129+
@ValueSource(strings = {
130+
"SELECT v:a.output + 1, \"F\".\"X\":\"a\".from::VARIANT AS y FROM t F",
131+
"SELECT * FROM t WHERE v:payload.output::INT = 1 ORDER BY v:payload.from",
132+
"SELECT v:a.output FROM t, lateral flatten(input => t.v:a.input.where) f"})
133+
void testKeywordAfterDotInPathInContext(String sql) throws JSQLParserException {
134+
assertSqlCanBeParsedAndDeparsed(sql, true, parser -> parser.withDialect(Dialect.SNOWFLAKE));
135+
}
136+
137+
@Test
138+
void testKeywordAfterDotOutsideJsonPathIsStillRejected() {
139+
// a reserved word is not a part of a qualified name, only of a JSON path
140+
Assertions.assertThrows(JSQLParserException.class,
141+
() -> CCJSqlParserUtil.parse("SELECT t.output FROM t",
142+
parser -> parser.withDialect(Dialect.SNOWFLAKE)));
143+
}
144+
115145
@Test
116146
void testKeywordFollowedByParenthesisStaysFunction() throws JSQLParserException {
117147
// only parsing is asserted: a function behind ":" deparses with "."

‎src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ public void testStatementsTheCorpusLacks() throws JSQLParserException {
171171
"DELETE FROM t USING s JOIN u ON s.id = u.id, v WHERE t.id = s.id"}) {
172172
new Walk(sql, sql, problems).check(CCJSqlParserUtil.parseStatements(sql));
173173
}
174+
for (String sql : new String[] {"SELECT v:select FROM t", "SELECT v:select::INT FROM t",
175+
"SELECT v:select.x[0], v:from::INT + 1 FROM t",
176+
"SELECT v:a.output, v:a.b.from::INT, v:a[0].from.x[1] + 1 FROM t"}) {
177+
new Walk(sql, sql, problems).check(CCJSqlParserUtil.parseStatements(sql,
178+
parser -> parser.withDialect(AbstractJSqlParser.Dialect.SNOWFLAKE)));
179+
}
174180
String block = "BEGIN x := 1; NULL; foo(1); END;";
175181
new Walk(block, block, problems).check(CCJSqlParserUtil.parseStatements(block,
176182
parser -> parser.withDialect(AbstractJSqlParser.Dialect.ORACLE)));

0 commit comments

Comments
 (0)