Skip to content

Commit e125308

Browse files
fix: allow keywords as Snowflake JSON path names
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent e27929f commit e125308

3 files changed

Lines changed: 159 additions & 10 deletions

File tree

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

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,6 +1087,30 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
10871087
return left;
10881088
}
10891089

1090+
/**
1091+
* Whether the token after the ":" of a JSON path is a word that is no identifier token, i.e. a
1092+
* keyword, which names the path element all the same. A word followed by "(" stays a function.
1093+
*/
1094+
private boolean isKeywordJsonPathName() {
1095+
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)) {
1100+
return false;
1101+
}
1102+
String image = word.image;
1103+
if (!Character.isLetter(image.charAt(0)) && image.charAt(0) != '_') {
1104+
return false;
1105+
}
1106+
for (int i = 1; i < image.length(); i++) {
1107+
if (!Character.isLetterOrDigit(image.charAt(i)) && image.charAt(i) != '_') {
1108+
return false;
1109+
}
1110+
}
1111+
return true;
1112+
}
1113+
10901114
private boolean isPostgreSqlJsonAccess(Token token) {
10911115
return ("->".equals(token.image) || "->>".equals(token.image)
10921116
|| "#>".equals(token.image) || "#>>".equals(token.image))
@@ -14586,11 +14610,7 @@ Expression PrimaryExpression() #PrimaryExpression:
1458614610
token="#>>"
1458714611
)
1458814612

14589-
(
14590-
LOOKAHEAD({getAsBoolean(Feature.allowComplexParsing) && !interrupted }) expression=Expression()
14591-
|
14592-
expression=SimpleExpression()
14593-
)
14613+
expression=JsonPathElement()
1459414614
{
1459514615
if (jsonIdents == null) {
1459614616
jsonIdents = new ArrayList<Map.Entry<Expression, String>>();
@@ -14948,6 +14968,58 @@ StructType StructType() #StruckType:
1494814968
}
1494914969
}
1495014970

14971+
/**
14972+
* The element after a ":" in a JSON path (Snowflake, Databricks). A path name is mandatory there,
14973+
* so any word is a name, reserved or not: v:from::INT, v:select, v:output.x[0].
14974+
*/
14975+
Expression JsonPathElement() #void:
14976+
{ Expression element; }
14977+
{
14978+
(
14979+
LOOKAHEAD({ isKeywordJsonPathName() }) element=JsonPathKeyword()
14980+
|
14981+
LOOKAHEAD({getAsBoolean(Feature.allowComplexParsing) && !interrupted}) element=Expression()
14982+
|
14983+
element=SimpleExpression()
14984+
)
14985+
{ return element; }
14986+
}
14987+
14988+
/**
14989+
* A keyword used as a JSON path name. The token is taken as it is, as no token kind in the grammar
14990+
* stands for "any keyword", then the path name continues like a primary expression: further
14991+
* navigation steps, casts and the arithmetic tier SimpleExpression() also applies.
14992+
*/
14993+
Expression JsonPathKeyword() #void:
14994+
{
14995+
Token first = getToken(1);
14996+
Token keyword;
14997+
Expression element;
14998+
Expression step;
14999+
ColDataType type;
15000+
CastExpression cast;
15001+
}
15002+
{
15003+
{
15004+
keyword = getNextToken();
15005+
element = linkToLastToken(new Column(keyword.image));
15006+
}
15007+
( LOOKAHEAD(2) { linkASTIfMissing(element, first); } step=NavigationStep(element) { element = step; } )*
15008+
(
15009+
LOOKAHEAD(2) { linkASTIfMissing(element, first); } "::" type=ColDataType() {
15010+
cast = new CastExpression();
15011+
cast.setUseCastKeyword(false);
15012+
cast.setLeftExpression(element);
15013+
cast.setColDataType(type);
15014+
element = cast;
15015+
}
15016+
)*
15017+
{
15018+
int arithFloor = Dialect.MYSQL.name().equals(getAsString(Feature.dialect)) ? 2 : 4;
15019+
return prattArithRest(element, first, arithFloor);
15020+
}
15021+
}
15022+
1495115023
JsonExpression JsonExpression(Expression expr, List<Map.Entry<Expression, String>> idents) : {
1495215024
JsonExpression result = new JsonExpression(expr, idents);
1495315025
Expression expression;
@@ -14991,11 +15063,7 @@ JsonExpression JsonExpression(Expression expr, List<Map.Entry<Expression, String
1499115063
token="#>>"
1499215064
)
1499315065

14994-
(
14995-
LOOKAHEAD({getAsBoolean(Feature.allowComplexParsing) && !interrupted}) expression=Expression()
14996-
|
14997-
expression=SimpleExpression()
14998-
)
15066+
expression=JsonPathElement()
1499915067
{
1500015068
result.addIdent( expression, token.image );
1500115069
}

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
package net.sf.jsqlparser.expression;
1111

1212
import net.sf.jsqlparser.JSQLParserException;
13+
import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect;
1314
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
1415
import net.sf.jsqlparser.statement.select.PlainSelect;
1516
import org.junit.jupiter.api.Assertions;
@@ -55,6 +56,69 @@ void testHashArrowOperatorsRoundTrip() throws JSQLParserException {
5556
}
5657
}
5758

59+
@ParameterizedTest
60+
@ValueSource(strings = {"select", "from", "where", "join", "union", "and", "or", "not", "case",
61+
"when", "else", "as", "is", "like", "having", "distinct", "inner", "into", "with",
62+
"outer", "cross", "using", "output", "null", "order", "group"})
63+
void testSnowflakeKeywordAsPathName(String keyword) throws JSQLParserException {
64+
String sqlStr = "SELECT v:" + keyword + "::INT, v:" + keyword + ".x[0] FROM vartab";
65+
PlainSelect st = (PlainSelect) assertSqlCanBeParsedAndDeparsed(sqlStr, true);
66+
Assertions.assertInstanceOf(JsonExpression.class, st.getSelectItem(0).getExpression());
67+
Assertions.assertInstanceOf(JsonExpression.class, st.getSelectItem(1).getExpression());
68+
}
69+
70+
@Test
71+
void testSnowflakeKeywordPathNameBeforeClause() throws JSQLParserException {
72+
assertSqlCanBeParsedAndDeparsed(
73+
"SELECT v:from FROM t WHERE v:where::INT = 1 ORDER BY v:order",
74+
true);
75+
}
76+
77+
@Test
78+
void testSnowflakeKeywordPathNameInInsertSelect() throws JSQLParserException {
79+
assertSqlCanBeParsedAndDeparsed("INSERT INTO \"MY_DB\".\"MY_SCHEMA\".\"T\" (\"c\")\n"
80+
+ "SELECT \"F\".\"DATA_VALUE\":window::VARCHAR FROM \"MY_DB\".\"MY_SCHEMA\".\"RAW_TRACES\" AS \"F\"",
81+
true);
82+
}
83+
84+
@Test
85+
void testSnowflakeKeywordPathNameOnLateralFlatten() throws JSQLParserException {
86+
assertSqlCanBeParsedAndDeparsed("INSERT INTO \"MY_DB\".\"MY_SCHEMA\".\"OBS\" (\"o\")\n"
87+
+ "SELECT \"obs\".VALUE:output::VARIANT\n"
88+
+ "FROM \"MY_DB\".\"MY_SCHEMA\".\"RAW_TRACES\" AS \"F\",\n"
89+
+ " lateral flatten(input=>\"F\".\"DATA_VALUE\":observations, outer => true, mode => 'ARRAY') AS \"obs\"",
90+
true);
91+
}
92+
93+
@ParameterizedTest
94+
@ValueSource(strings = {"SELECT v:from::INT, v:output.x[0] FROM t",
95+
"SELECT v:select + 1, v:where * 2 - v:from FROM t",
96+
"SELECT v:window::INT + 1 FROM t WHERE v:order::INT > 2"})
97+
void testKeywordPathNameDatabricksAndMysqlArithmetic(String sql) throws JSQLParserException {
98+
assertSqlCanBeParsedAndDeparsed(sql, true,
99+
parser -> parser.withDialect(Dialect.DATABRICKS));
100+
assertSqlCanBeParsedAndDeparsed(sql, true, parser -> parser.withDialect(Dialect.SNOWFLAKE));
101+
assertSqlCanBeParsedAndDeparsed(sql, true, parser -> parser.withDialect(Dialect.MYSQL));
102+
}
103+
104+
@ParameterizedTest
105+
@ValueSource(strings = {
106+
"SELECT coalesce(v:from, v:select::INT) FROM t",
107+
"SELECT CASE WHEN v:when::INT = 1 THEN v:else ELSE v:end END FROM t",
108+
"SELECT * FROM (SELECT v:from AS a FROM t) x WHERE x.a IN (SELECT v:where FROM u)",
109+
"SELECT * FROM t WHERE EXISTS (SELECT 1 FROM u WHERE u.v:order::INT = t.id)",
110+
"SELECT f(v:window, (SELECT w:output FROM u)) FROM t"})
111+
void testKeywordPathNameInsideNestedConstructs(String sql) throws JSQLParserException {
112+
assertSqlCanBeParsedAndDeparsed(sql, true, parser -> parser.withDialect(Dialect.SNOWFLAKE));
113+
}
114+
115+
@Test
116+
void testKeywordFollowedByParenthesisStaysFunction() throws JSQLParserException {
117+
// only parsing is asserted: a function behind ":" deparses with "."
118+
Assertions.assertNotNull(CCJSqlParserUtil.parse("SELECT v:left(x) FROM t",
119+
parser -> parser.withDialect(Dialect.SNOWFLAKE)));
120+
}
121+
58122
@Test
59123
void testSnowflakeGetOperator() throws JSQLParserException {
60124
// https://docs.snowflake.com/en/user-guide/querying-semistructured

‎src/test/java/net/sf/jsqlparser/statement/create/CreateTableTest.java‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,4 +1388,21 @@ void testPostgreSqlDeclarativePartitionChildrenIssue2522() throws JSQLParserExce
13881388
defaultSql, true, parser -> parser.withDialect(Dialect.POSTGRESQL));
13891389
assertEquals(PartitionBound.Type.DEFAULT, defaultPartition.getPartitionBound().getType());
13901390
}
1391+
1392+
@Test
1393+
void testCreateOrReplaceTableCopyGrantsAsSelectFromFlattenedVariant()
1394+
throws JSQLParserException {
1395+
String sql =
1396+
"CREATE OR REPLACE TABLE \"MY_DB\".\"MY_SCHEMA\".\"STAGING_TRACES\" COPY GRANTS\n"
1397+
+ "AS (SELECT\n"
1398+
+ " *\n"
1399+
+ "FROM (SELECT\n"
1400+
+ " \"FLATTEN_VARIANT_INPUT\".\"DATA_VALUE\":id::VARCHAR AS \"id\",\n"
1401+
+ " \"FLATTEN_VARIANT_INPUT\".\"DATA_VALUE\":input::VARIANT AS \"input\",\n"
1402+
+ " \"FLATTEN_VARIANT_INPUT\".\"DATA_VALUE\":output::VARIANT AS \"output\"\n"
1403+
+ "FROM (SELECT\n"
1404+
+ " \"DATA_VALUE\"\n"
1405+
+ " FROM \"MY_DB\".\"MY_SCHEMA\".\"RAW_TRACES\") AS \"FLATTEN_VARIANT_INPUT\"))";
1406+
assertSqlCanBeParsedAndDeparsed(sql, true, parser -> parser.withDialect(Dialect.SNOWFLAKE));
1407+
}
13911408
}

0 commit comments

Comments
 (0)