diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index e4fcc2e76..ddd74d2e1 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1087,6 +1087,82 @@ 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); + 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 (image.isEmpty() || (!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; + } + + /** + * 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)) @@ -14586,11 +14662,7 @@ Expression PrimaryExpression() #PrimaryExpression: token="#>>" ) - ( - LOOKAHEAD({getAsBoolean(Feature.allowComplexParsing) && !interrupted }) expression=Expression() - | - expression=SimpleExpression() - ) + expression=JsonPathElement() { if (jsonIdents == null) { jsonIdents = new ArrayList>(); @@ -14948,6 +15020,75 @@ 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() || isJsonPathWithKeywordAfterDot() }) 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) step=JsonPathStep(element) { element = linked(step, first); } )* + ( + LOOKAHEAD(2) "::" type=ColDataType() { + cast = new CastExpression(); + cast.setUseCastKeyword(false); + cast.setLeftExpression(element); + cast.setColDataType(type); + element = linked(cast, first); + } + )* + { + int arithFloor = Dialect.MYSQL.name().equals(getAsString(Feature.dialect)) ? 2 : 4; + return prattArithRest(element, first, arithFloor); + } +} + +/** + * 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; @@ -14991,11 +15132,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)); + } + + @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 "." + 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/parser/ASTNodeCoverageTest.java b/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java index f65d16d82..8988258fb 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))); 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 26bc31cc7..fe1cbee37 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)); + } }