From e12530895a85ef8fec3d5ad37e1f47d8f7b34ead Mon Sep 17 00:00:00 2001 From: harryfoster2006 Date: Wed, 11 Nov 2026 15:14:40 +0000 Subject: [PATCH 1/2] fix: allow keywords as Snowflake JSON path names Co-Authored-By: Claude Sonnet 5.5 --- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 88 ++++++++++++++++--- .../expression/JsonExpressionTest.java | 64 ++++++++++++++ .../statement/create/CreateTableTest.java | 17 ++++ 3 files changed, 159 insertions(+), 10 deletions(-) diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index e4fcc2e76e..58cbb35e1e 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1087,6 +1087,30 @@ public class CCJSqlParser extends AbstractJSqlParser { return left; } + /** + * Whether the token after the ":" of a JSON path is a word that is no identifier token, i.e. a + * keyword, which names the path element all the same. A word followed by "(" stays a function. + */ + private boolean isKeywordJsonPathName() { + Token colon = getToken(0); + Token word = getToken(1); + if (colon == null || !":".equals(colon.image) || word.image == null + || word.kind == S_IDENTIFIER || word.kind == S_QUOTED_IDENTIFIER + || "(".equals(getToken(2).image)) { + return false; + } + String image = word.image; + if (!Character.isLetter(image.charAt(0)) && image.charAt(0) != '_') { + return false; + } + for (int i = 1; i < image.length(); i++) { + if (!Character.isLetterOrDigit(image.charAt(i)) && image.charAt(i) != '_') { + return false; + } + } + return true; + } + private boolean isPostgreSqlJsonAccess(Token token) { return ("->".equals(token.image) || "->>".equals(token.image) || "#>".equals(token.image) || "#>>".equals(token.image)) @@ -14586,11 +14610,7 @@ Expression PrimaryExpression() #PrimaryExpression: token="#>>" ) - ( - LOOKAHEAD({getAsBoolean(Feature.allowComplexParsing) && !interrupted }) expression=Expression() - | - expression=SimpleExpression() - ) + expression=JsonPathElement() { if (jsonIdents == null) { jsonIdents = new ArrayList>(); @@ -14948,6 +14968,58 @@ StructType StructType() #StruckType: } } +/** + * The element after a ":" in a JSON path (Snowflake, Databricks). A path name is mandatory there, + * so any word is a name, reserved or not: v:from::INT, v:select, v:output.x[0]. + */ +Expression JsonPathElement() #void: +{ Expression element; } +{ + ( + LOOKAHEAD({ isKeywordJsonPathName() }) element=JsonPathKeyword() + | + LOOKAHEAD({getAsBoolean(Feature.allowComplexParsing) && !interrupted}) element=Expression() + | + element=SimpleExpression() + ) + { return element; } +} + +/** + * A keyword used as a JSON path name. The token is taken as it is, as no token kind in the grammar + * stands for "any keyword", then the path name continues like a primary expression: further + * navigation steps, casts and the arithmetic tier SimpleExpression() also applies. + */ +Expression JsonPathKeyword() #void: +{ + Token first = getToken(1); + Token keyword; + Expression element; + Expression step; + ColDataType type; + CastExpression cast; +} +{ + { + keyword = getNextToken(); + element = linkToLastToken(new Column(keyword.image)); + } + ( LOOKAHEAD(2) { linkASTIfMissing(element, first); } step=NavigationStep(element) { element = step; } )* + ( + LOOKAHEAD(2) { linkASTIfMissing(element, first); } "::" type=ColDataType() { + cast = new CastExpression(); + cast.setUseCastKeyword(false); + cast.setLeftExpression(element); + cast.setColDataType(type); + element = cast; + } + )* + { + int arithFloor = Dialect.MYSQL.name().equals(getAsString(Feature.dialect)) ? 2 : 4; + return prattArithRest(element, first, arithFloor); + } +} + JsonExpression JsonExpression(Expression expr, List> idents) : { JsonExpression result = new JsonExpression(expr, idents); Expression expression; @@ -14991,11 +15063,7 @@ JsonExpression JsonExpression(Expression expr, List\"F\".\"DATA_VALUE\":observations, outer => true, mode => 'ARRAY') AS \"obs\"", + true); + } + + @ParameterizedTest + @ValueSource(strings = {"SELECT v:from::INT, v:output.x[0] FROM t", + "SELECT v:select + 1, v:where * 2 - v:from FROM t", + "SELECT v:window::INT + 1 FROM t WHERE v:order::INT > 2"}) + void testKeywordPathNameDatabricksAndMysqlArithmetic(String sql) throws JSQLParserException { + assertSqlCanBeParsedAndDeparsed(sql, true, + parser -> parser.withDialect(Dialect.DATABRICKS)); + assertSqlCanBeParsedAndDeparsed(sql, true, parser -> parser.withDialect(Dialect.SNOWFLAKE)); + assertSqlCanBeParsedAndDeparsed(sql, true, parser -> parser.withDialect(Dialect.MYSQL)); + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT coalesce(v:from, v:select::INT) FROM t", + "SELECT CASE WHEN v:when::INT = 1 THEN v:else ELSE v:end END FROM t", + "SELECT * FROM (SELECT v:from AS a FROM t) x WHERE x.a IN (SELECT v:where FROM u)", + "SELECT * FROM t WHERE EXISTS (SELECT 1 FROM u WHERE u.v:order::INT = t.id)", + "SELECT f(v:window, (SELECT w:output FROM u)) FROM t"}) + void testKeywordPathNameInsideNestedConstructs(String sql) throws JSQLParserException { + assertSqlCanBeParsedAndDeparsed(sql, true, parser -> parser.withDialect(Dialect.SNOWFLAKE)); + } + + @Test + void testKeywordFollowedByParenthesisStaysFunction() throws JSQLParserException { + // only parsing is asserted: a function behind ":" deparses with "." + Assertions.assertNotNull(CCJSqlParserUtil.parse("SELECT v:left(x) FROM t", + parser -> parser.withDialect(Dialect.SNOWFLAKE))); + } + @Test void testSnowflakeGetOperator() throws JSQLParserException { // https://docs.snowflake.com/en/user-guide/querying-semistructured diff --git a/src/test/java/net/sf/jsqlparser/statement/create/CreateTableTest.java b/src/test/java/net/sf/jsqlparser/statement/create/CreateTableTest.java index 26bc31cc71..fe1cbee377 100644 --- a/src/test/java/net/sf/jsqlparser/statement/create/CreateTableTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/create/CreateTableTest.java @@ -1388,4 +1388,21 @@ void testPostgreSqlDeclarativePartitionChildrenIssue2522() throws JSQLParserExce defaultSql, true, parser -> parser.withDialect(Dialect.POSTGRESQL)); assertEquals(PartitionBound.Type.DEFAULT, defaultPartition.getPartitionBound().getType()); } + + @Test + void testCreateOrReplaceTableCopyGrantsAsSelectFromFlattenedVariant() + throws JSQLParserException { + String sql = + "CREATE OR REPLACE TABLE \"MY_DB\".\"MY_SCHEMA\".\"STAGING_TRACES\" COPY GRANTS\n" + + "AS (SELECT\n" + + " *\n" + + "FROM (SELECT\n" + + " \"FLATTEN_VARIANT_INPUT\".\"DATA_VALUE\":id::VARCHAR AS \"id\",\n" + + " \"FLATTEN_VARIANT_INPUT\".\"DATA_VALUE\":input::VARIANT AS \"input\",\n" + + " \"FLATTEN_VARIANT_INPUT\".\"DATA_VALUE\":output::VARIANT AS \"output\"\n" + + "FROM (SELECT\n" + + " \"DATA_VALUE\"\n" + + " FROM \"MY_DB\".\"MY_SCHEMA\".\"RAW_TRACES\") AS \"FLATTEN_VARIANT_INPUT\"))"; + assertSqlCanBeParsedAndDeparsed(sql, true, parser -> parser.withDialect(Dialect.SNOWFLAKE)); + } } From 9fc19801b0c13abe4f4851436e0e32d84f0d6eba Mon Sep 17 00:00:00 2001 From: harryfoster2006 Date: Wed, 11 Nov 2026 15:57:57 +0000 Subject: [PATCH 2/2] 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 --- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 87 +++++++++++++++++-- .../expression/JsonExpressionTest.java | 30 +++++++ .../parser/ASTNodeCoverageTest.java | 6 ++ 3 files changed, 114 insertions(+), 9 deletions(-) diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 58cbb35e1e..ddd74d2e1d 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1093,14 +1093,21 @@ public class CCJSqlParser extends AbstractJSqlParser { */ private boolean isKeywordJsonPathName() { Token colon = getToken(0); - Token word = getToken(1); - if (colon == null || !":".equals(colon.image) || word.image == null - || word.kind == S_IDENTIFIER || word.kind == S_QUOTED_IDENTIFIER - || "(".equals(getToken(2).image)) { + return colon != null && ":".equals(colon.image) && isKeywordWord(1); + } + + /** + * Whether the token at the given position is a word that is no identifier token, i.e. a keyword, + * and not the name of a function call. + */ + private boolean isKeywordWord(int position) { + Token word = getToken(position); + if (word.image == null || word.kind == S_IDENTIFIER || word.kind == S_QUOTED_IDENTIFIER + || "(".equals(getToken(position + 1).image)) { return false; } String image = word.image; - if (!Character.isLetter(image.charAt(0)) && image.charAt(0) != '_') { + if (image.isEmpty() || (!Character.isLetter(image.charAt(0)) && image.charAt(0) != '_')) { return false; } for (int i = 1; i < image.length(); i++) { @@ -1111,6 +1118,51 @@ public class CCJSqlParser extends AbstractJSqlParser { return true; } + /** + * Whether the path after the ":" of a JSON path, which starts with an identifier, goes on with a + * keyword after a ".", e.g. v:a.output or v:a[0].b.from. The expression parser would read such a + * path as a qualified column name, which does not take a keyword as a part. + */ + private boolean isJsonPathWithKeywordAfterDot() { + Token colon = getToken(0); + if (colon == null || !":".equals(colon.image)) { + return false; + } + Token first = getToken(1); + if (first.kind != S_IDENTIFIER && first.kind != S_QUOTED_IDENTIFIER) { + return false; + } + int position = 2; + while (true) { + Token token = getToken(position); + if (".".equals(token.image)) { + if (isKeywordWord(position + 1)) { + return true; + } + Token name = getToken(position + 1); + if (name.kind != S_IDENTIFIER && name.kind != S_QUOTED_IDENTIFIER) { + return false; + } + position += 2; + } else if ("[".equals(token.image)) { + int depth = 0; + do { + token = getToken(position); + if ("[".equals(token.image)) { + depth++; + } else if ("]".equals(token.image)) { + depth--; + } else if (token.kind == EOF) { + return false; + } + position++; + } while (depth > 0); + } else { + return false; + } + } + } + private boolean isPostgreSqlJsonAccess(Token token) { return ("->".equals(token.image) || "->>".equals(token.image) || "#>".equals(token.image) || "#>>".equals(token.image)) @@ -14976,7 +15028,7 @@ Expression JsonPathElement() #void: { Expression element; } { ( - LOOKAHEAD({ isKeywordJsonPathName() }) element=JsonPathKeyword() + LOOKAHEAD({ isKeywordJsonPathName() || isJsonPathWithKeywordAfterDot() }) element=JsonPathKeyword() | LOOKAHEAD({getAsBoolean(Feature.allowComplexParsing) && !interrupted}) element=Expression() | @@ -15004,14 +15056,14 @@ Expression JsonPathKeyword() #void: keyword = getNextToken(); element = linkToLastToken(new Column(keyword.image)); } - ( LOOKAHEAD(2) { linkASTIfMissing(element, first); } step=NavigationStep(element) { element = step; } )* + ( LOOKAHEAD(2) step=JsonPathStep(element) { element = linked(step, first); } )* ( - LOOKAHEAD(2) { linkASTIfMissing(element, first); } "::" type=ColDataType() { + LOOKAHEAD(2) "::" type=ColDataType() { cast = new CastExpression(); cast.setUseCastKeyword(false); cast.setLeftExpression(element); cast.setColDataType(type); - element = cast; + element = linked(cast, first); } )* { @@ -15020,6 +15072,23 @@ Expression JsonPathKeyword() #void: } } +/** + * A navigation step within a JSON path, where a keyword after a "." names a path element as well: + * v:a.output, v:a[0].from. + */ +Expression JsonPathStep(Expression base) #void: +{ Expression step; } +{ + ( + LOOKAHEAD({ ".".equals(getToken(1).image) && isKeywordWord(2) }) "." { + step = new RowGetExpression(base, getNextToken().image); + } + | + step=NavigationStep(base) + ) + { return step; } +} + JsonExpression JsonExpression(Expression expr, List> idents) : { JsonExpression result = new JsonExpression(expr, idents); Expression expression; diff --git a/src/test/java/net/sf/jsqlparser/expression/JsonExpressionTest.java b/src/test/java/net/sf/jsqlparser/expression/JsonExpressionTest.java index da5d3c3e92..ad2a8eb5c8 100644 --- a/src/test/java/net/sf/jsqlparser/expression/JsonExpressionTest.java +++ b/src/test/java/net/sf/jsqlparser/expression/JsonExpressionTest.java @@ -112,6 +112,36 @@ void testKeywordPathNameInsideNestedConstructs(String sql) throws JSQLParserExce assertSqlCanBeParsedAndDeparsed(sql, true, parser -> parser.withDialect(Dialect.SNOWFLAKE)); } + @ParameterizedTest + @ValueSource(strings = {"select", "from", "where", "join", "union", "and", "or", "not", "case", + "when", "else", "as", "is", "like", "having", "distinct", "inner", "into", "with", + "outer", "cross", "using", "output", "null", "true", "false", "order", "group"}) + void testSnowflakeKeywordAfterDotInPath(String keyword) throws JSQLParserException { + String sqlStr = "SELECT v:a." + keyword + ", v:a.b." + keyword + "::INT, v:a[0]." + keyword + + ".x[1] FROM vartab"; + PlainSelect st = (PlainSelect) assertSqlCanBeParsedAndDeparsed(sqlStr, true); + for (int i = 0; i < 3; i++) { + Assertions.assertInstanceOf(JsonExpression.class, st.getSelectItem(i).getExpression()); + } + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT v:a.output + 1, \"F\".\"X\":\"a\".from::VARIANT AS y FROM t F", + "SELECT * FROM t WHERE v:payload.output::INT = 1 ORDER BY v:payload.from", + "SELECT v:a.output FROM t, lateral flatten(input => t.v:a.input.where) f"}) + void testKeywordAfterDotInPathInContext(String sql) throws JSQLParserException { + assertSqlCanBeParsedAndDeparsed(sql, true, parser -> parser.withDialect(Dialect.SNOWFLAKE)); + } + + @Test + void testKeywordAfterDotOutsideJsonPathIsStillRejected() { + // a reserved word is not a part of a qualified name, only of a JSON path + Assertions.assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse("SELECT t.output FROM t", + parser -> parser.withDialect(Dialect.SNOWFLAKE))); + } + @Test void testKeywordFollowedByParenthesisStaysFunction() throws JSQLParserException { // only parsing is asserted: a function behind ":" deparses with "." diff --git a/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java b/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java index f65d16d82e..8988258fba 100644 --- a/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java +++ b/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java @@ -171,6 +171,12 @@ public void testStatementsTheCorpusLacks() throws JSQLParserException { "DELETE FROM t USING s JOIN u ON s.id = u.id, v WHERE t.id = s.id"}) { new Walk(sql, sql, problems).check(CCJSqlParserUtil.parseStatements(sql)); } + for (String sql : new String[] {"SELECT v:select FROM t", "SELECT v:select::INT FROM t", + "SELECT v:select.x[0], v:from::INT + 1 FROM t", + "SELECT v:a.output, v:a.b.from::INT, v:a[0].from.x[1] + 1 FROM t"}) { + new Walk(sql, sql, problems).check(CCJSqlParserUtil.parseStatements(sql, + parser -> parser.withDialect(AbstractJSqlParser.Dialect.SNOWFLAKE))); + } String block = "BEGIN x := 1; NULL; foo(1); END;"; new Walk(block, block, problems).check(CCJSqlParserUtil.parseStatements(block, parser -> parser.withDialect(AbstractJSqlParser.Dialect.ORACLE)));