diff --git a/parser/keyword_test.go b/parser/keyword_test.go index 5570fcc..f14a1f0 100644 --- a/parser/keyword_test.go +++ b/parser/keyword_test.go @@ -86,6 +86,7 @@ func TestReservedKeywordInDisambiguatedPositions(t *testing.T) { "SELECT * FROM t AS from", "SELECT a FROM db.from", "SELECT t.from FROM t", + "SELECT kill.id FROM events AS kill", "SELECT a, limit FROM t", "SELECT case;", "SELECT limit", diff --git a/parser/parser_column.go b/parser/parser_column.go index ad53333..30de285 100644 --- a/parser/parser_column.go +++ b/parser/parser_column.go @@ -187,12 +187,19 @@ func (p *Parser) parseInfix(expr Expr, precedence int) (Expr, error) { }, nil case p.matchTokenKind(TokenKindDot): _ = p.lexer.consumeToken() - // access column with dot notation + operation := TokenKindDot + hasTypeQualifier := p.tryConsumeTokenKind(TokenKindColon) != nil + if hasTypeQualifier { + // Dynamic JSON subcolumns can pin their result type with + // `.:Type`, for example `json.path.:`Array(JSON)``. + operation = TokenKindDot + TokenKindColon + } + var rightExpr Expr var err error - if p.matchTokenKind(TokenKindIdent, TokenKindKeyword) { - // After a dot the token can only be a member name, so even - // reserved keywords are accepted (e.g. `t.from`). + if hasTypeQualifier || p.matchTokenKind(TokenKindIdent, TokenKindKeyword) { + // After a dot the token can only be a member name or type + // qualifier, so even reserved keywords are accepted. rightExpr, err = p.parseAnyKeyword() } else { rightExpr, err = p.parseDecimal(p.Pos()) @@ -202,7 +209,7 @@ func (p *Parser) parseInfix(expr Expr, precedence int) (Expr, error) { } return &IndexOperation{ Object: expr, - Operation: TokenKindDot, + Operation: operation, Index: rightExpr, }, nil case p.matchKeyword(KeywordNot): @@ -527,7 +534,7 @@ func (p *Parser) peekIsExpressionContinuation() bool { // queries like `SELECT a, limit FROM t` or `SELECT a, from, b FROM t` // without backtick escaping. Backticked identifiers are tokenized as // TokenKindIdent (not TokenKindKeyword), so trailing-comma handling for -// keyword-named tables — e.g. `SELECT count(*), FROM `limit`` — is preserved. +// keyword-named tables — e.g. `SELECT count(*), FROM `limit“ — is preserved. // // End-of-statement (EOF or `;`) is intentionally NOT included here. It's a // valid disambiguator only in expression position (the current keyword IS @@ -556,9 +563,18 @@ func (p *Parser) isSelectItemTerminatorKeyword() bool { } func (p *Parser) parseColumnExpr(pos Pos) (Expr, error) { //nolint:funlen - // Should parse the keyword as an identifier if the keyword is followed by - // `,`, `AS`, another clause-starter keyword, or end-of-statement (EOF or - // `;`). ClickHouse accepts most reserved words as bare column names in + // A keyword followed by a dot is unambiguously the left side of a + // qualified column reference, even when it is otherwise reserved (for + // example, `kill.item_id`). INTERVAL must reach its dedicated parser + // first because a dot can also start its numeric operand. + if !p.matchKeyword(KeywordInterval) && + p.matchTokenKind(TokenKindKeyword) && p.peekTokenKind(TokenKindDot) { + return p.parseIdentOrFunction(pos) + } + + // Parse the keyword as an identifier if it is followed by `,`, `AS`, + // another clause-starter keyword, or end-of-statement (EOF or `;`). + // ClickHouse accepts most reserved words as bare column names in // projections (e.g. `SELECT 1 AS interval GROUP BY interval`, // `SELECT a, case FROM t`, `SELECT case`); a clause/expression starter // always requires a value/expression next, so the lookahead unambiguously diff --git a/parser/parser_table.go b/parser/parser_table.go index 30bd1d9..6525050 100644 --- a/parser/parser_table.go +++ b/parser/parser_table.go @@ -403,9 +403,10 @@ func (p *Parser) parseCreateTable(pos Pos, orReplace bool) (*CreateTable, error) func (p *Parser) parseIdentOrFunction(_ Pos) (Expr, error) { var ident *Ident var err error - if p.matchTokenKind(TokenKindKeyword) && p.peekTokenKind(TokenKindLParen) { - // reserved operator keywords stay callable as ordinary functions: - // and(a, b), or(a, b), in(x, set), like(s, pat), ... + if p.matchTokenKind(TokenKindKeyword) && + (p.peekTokenKind(TokenKindLParen) || p.peekTokenKind(TokenKindDot)) { + // Reserved keywords remain valid when context proves they are function + // names or the first field of a qualified name. ident, err = p.parseAnyKeyword() } else { ident, err = p.parseIdent() @@ -466,7 +467,8 @@ func (p *Parser) parseIdentOrFunction(_ Pos) (Expr, error) { }, nil } return funcExpr, nil - case p.tryConsumeTokenKind(TokenKindDot) != nil: + case p.matchTokenKind(TokenKindDot) && !p.peekTokenKind(TokenKindColon): + _ = p.lexer.consumeToken() switch { case p.matchTokenKind(TokenKindIdent, TokenKindKeyword): fields := []*Ident{ident} @@ -478,9 +480,10 @@ func (p *Parser) parseIdentOrFunction(_ Pos) (Expr, error) { return nil, err } fields = append(fields, child) - if p.tryConsumeTokenKind(TokenKindDot) == nil { + if !p.matchTokenKind(TokenKindDot) || p.peekTokenKind(TokenKindColon) { break } + _ = p.lexer.consumeToken() } return &Path{Fields: fields}, nil case p.matchTokenKind("*"): diff --git a/parser/precedence_test.go b/parser/precedence_test.go index 82dbe6b..3222de4 100644 --- a/parser/precedence_test.go +++ b/parser/precedence_test.go @@ -520,10 +520,17 @@ func TestIntervalAsColumnName(t *testing.T) { } func TestIntervalOperatorStillParses(t *testing.T) { - expr := parseSelectItemExpr(t, "SELECT INTERVAL 4 DAY") - interval, ok := expr.(*IntervalExpr) - require.True(t, ok, "expected *IntervalExpr, got %T", expr) - require.Equal(t, "DAY", interval.Unit.Name) + for _, sql := range []string{ + "SELECT INTERVAL 4 DAY", + "SELECT INTERVAL .1 DAY", + } { + t.Run(sql, func(t *testing.T) { + expr := parseSelectItemExpr(t, sql) + interval, ok := expr.(*IntervalExpr) + require.True(t, ok, "expected *IntervalExpr, got %T", expr) + require.Equal(t, "DAY", interval.Unit.Name) + }) + } } func TestRepeatedIntervalColumnsParseInPolynomialTime(t *testing.T) { diff --git a/parser/testdata/basic/format/beautify/interval_expr.sql b/parser/testdata/basic/format/beautify/interval_expr.sql index 73b1fbd..3d5110f 100644 --- a/parser/testdata/basic/format/beautify/interval_expr.sql +++ b/parser/testdata/basic/format/beautify/interval_expr.sql @@ -1,5 +1,6 @@ -- Origin SQL: SELECT INTERVAL 4 DAY; +SELECT INTERVAL .1 DAY; SELECT INTERVAL a + b DAY; SELECT INTERVAL toUInt8(1) DAY; SELECT INTERVAL asc DAY; @@ -8,6 +9,8 @@ SELECT INTERVAL asc DAY; -- Beautify SQL: SELECT INTERVAL 4 DAY; +SELECT + INTERVAL .1 DAY; SELECT INTERVAL a + b DAY; SELECT diff --git a/parser/testdata/basic/format/interval_expr.sql b/parser/testdata/basic/format/interval_expr.sql index 6976b99..bdb8e7f 100644 --- a/parser/testdata/basic/format/interval_expr.sql +++ b/parser/testdata/basic/format/interval_expr.sql @@ -1,5 +1,6 @@ -- Origin SQL: SELECT INTERVAL 4 DAY; +SELECT INTERVAL .1 DAY; SELECT INTERVAL a + b DAY; SELECT INTERVAL toUInt8(1) DAY; SELECT INTERVAL asc DAY; @@ -7,6 +8,7 @@ SELECT INTERVAL asc DAY; -- Format SQL: SELECT INTERVAL 4 DAY; +SELECT INTERVAL .1 DAY; SELECT INTERVAL a + b DAY; SELECT INTERVAL toUInt8(1) DAY; SELECT INTERVAL asc DAY; diff --git a/parser/testdata/basic/interval_expr.sql b/parser/testdata/basic/interval_expr.sql index 2384cbd..c01ce25 100644 --- a/parser/testdata/basic/interval_expr.sql +++ b/parser/testdata/basic/interval_expr.sql @@ -1,4 +1,5 @@ SELECT INTERVAL 4 DAY; +SELECT INTERVAL .1 DAY; SELECT INTERVAL a + b DAY; SELECT INTERVAL toUInt8(1) DAY; SELECT INTERVAL asc DAY; diff --git a/parser/testdata/basic/output/interval_expr.sql.golden.json b/parser/testdata/basic/output/interval_expr.sql.golden.json index da79947..4e3ce1b 100644 --- a/parser/testdata/basic/output/interval_expr.sql.golden.json +++ b/parser/testdata/basic/output/interval_expr.sql.golden.json @@ -46,7 +46,7 @@ }, { "SelectPos": 23, - "StatementEnd": 48, + "StatementEnd": 45, "With": null, "Top": null, "HasDistinct": false, @@ -55,19 +55,64 @@ { "Expr": { "IntervalPos": 30, + "Expr": { + "NumPos": 39, + "NumEnd": 41, + "Literal": ".1", + "Base": 10 + }, + "Unit": { + "Name": "DAY", + "QuoteType": 1, + "NamePos": 42, + "NameEnd": 45 + } + }, + "Modifiers": [], + "Alias": null + } + ], + "From": null, + "Window": null, + "Prewhere": null, + "Where": null, + "GroupBy": null, + "WithTotal": false, + "Having": null, + "OrderBy": null, + "LimitBy": null, + "Limit": null, + "Settings": null, + "Format": null, + "UnionAll": null, + "UnionDistinct": null, + "Except": null, + "Intersect": null + }, + { + "SelectPos": 47, + "StatementEnd": 72, + "With": null, + "Top": null, + "HasDistinct": false, + "DistinctOn": null, + "SelectItems": [ + { + "Expr": { + "IntervalPos": 54, "Expr": { "LeftExpr": { "Name": "a", "QuoteType": 1, - "NamePos": 39, - "NameEnd": 40 + "NamePos": 63, + "NameEnd": 64 }, "Operation": "+", "RightExpr": { "Name": "b", "QuoteType": 1, - "NamePos": 43, - "NameEnd": 44 + "NamePos": 67, + "NameEnd": 68 }, "HasGlobal": false, "HasNot": false @@ -75,8 +120,8 @@ "Unit": { "Name": "DAY", "QuoteType": 1, - "NamePos": 45, - "NameEnd": 48 + "NamePos": 69, + "NameEnd": 72 } }, "Modifiers": [], @@ -101,8 +146,8 @@ "Intersect": null }, { - "SelectPos": 50, - "StatementEnd": 80, + "SelectPos": 74, + "StatementEnd": 104, "With": null, "Top": null, "HasDistinct": false, @@ -110,26 +155,26 @@ "SelectItems": [ { "Expr": { - "IntervalPos": 57, + "IntervalPos": 81, "Expr": { "Name": { "Name": "toUInt8", "QuoteType": 1, - "NamePos": 66, - "NameEnd": 73 + "NamePos": 90, + "NameEnd": 97 }, "Params": { - "LeftParenPos": 73, - "RightParenPos": 75, + "LeftParenPos": 97, + "RightParenPos": 99, "Items": { - "ListPos": 74, - "ListEnd": 75, + "ListPos": 98, + "ListEnd": 99, "HasDistinct": false, "Items": [ { "Expr": { - "NumPos": 74, - "NumEnd": 75, + "NumPos": 98, + "NumEnd": 99, "Literal": "1", "Base": 10 }, @@ -143,8 +188,8 @@ "Unit": { "Name": "DAY", "QuoteType": 1, - "NamePos": 77, - "NameEnd": 80 + "NamePos": 101, + "NameEnd": 104 } }, "Modifiers": [], @@ -169,8 +214,8 @@ "Intersect": null }, { - "SelectPos": 82, - "StatementEnd": 105, + "SelectPos": 106, + "StatementEnd": 129, "With": null, "Top": null, "HasDistinct": false, @@ -178,18 +223,18 @@ "SelectItems": [ { "Expr": { - "IntervalPos": 89, + "IntervalPos": 113, "Expr": { "Name": "asc", "QuoteType": 1, - "NamePos": 98, - "NameEnd": 101 + "NamePos": 122, + "NameEnd": 125 }, "Unit": { "Name": "DAY", "QuoteType": 1, - "NamePos": 102, - "NameEnd": 105 + "NamePos": 126, + "NameEnd": 129 } }, "Modifiers": [], diff --git a/parser/testdata/query/format/beautify/select_dynamic_subcolumn_type_hint.sql b/parser/testdata/query/format/beautify/select_dynamic_subcolumn_type_hint.sql new file mode 100644 index 0000000..7655724 --- /dev/null +++ b/parser/testdata/query/format/beautify/select_dynamic_subcolumn_type_hint.sql @@ -0,0 +1,10 @@ +-- Origin SQL: +SELECT payload.items.:`Array(JSON)` AS items +FROM source_rows + + +-- Beautify SQL: +SELECT + payload.items.:`Array(JSON)` AS items +FROM + source_rows; diff --git a/parser/testdata/query/format/beautify/select_reserved_keyword_qualifier.sql b/parser/testdata/query/format/beautify/select_reserved_keyword_qualifier.sql new file mode 100644 index 0000000..56c3f2b --- /dev/null +++ b/parser/testdata/query/format/beautify/select_reserved_keyword_qualifier.sql @@ -0,0 +1,10 @@ +-- Origin SQL: +SELECT kill.item_id AS item_id +FROM source_rows AS kill + + +-- Beautify SQL: +SELECT + kill.item_id AS item_id +FROM + source_rows AS kill; diff --git a/parser/testdata/query/format/select_dynamic_subcolumn_type_hint.sql b/parser/testdata/query/format/select_dynamic_subcolumn_type_hint.sql new file mode 100644 index 0000000..d5c6e1b --- /dev/null +++ b/parser/testdata/query/format/select_dynamic_subcolumn_type_hint.sql @@ -0,0 +1,7 @@ +-- Origin SQL: +SELECT payload.items.:`Array(JSON)` AS items +FROM source_rows + + +-- Format SQL: +SELECT payload.items.:`Array(JSON)` AS items FROM source_rows; diff --git a/parser/testdata/query/format/select_reserved_keyword_qualifier.sql b/parser/testdata/query/format/select_reserved_keyword_qualifier.sql new file mode 100644 index 0000000..5317368 --- /dev/null +++ b/parser/testdata/query/format/select_reserved_keyword_qualifier.sql @@ -0,0 +1,7 @@ +-- Origin SQL: +SELECT kill.item_id AS item_id +FROM source_rows AS kill + + +-- Format SQL: +SELECT kill.item_id AS item_id FROM source_rows AS kill; diff --git a/parser/testdata/query/output/select_dynamic_subcolumn_type_hint.sql.golden.json b/parser/testdata/query/output/select_dynamic_subcolumn_type_hint.sql.golden.json new file mode 100644 index 0000000..de8fabb --- /dev/null +++ b/parser/testdata/query/output/select_dynamic_subcolumn_type_hint.sql.golden.json @@ -0,0 +1,84 @@ +[ + { + "SelectPos": 0, + "StatementEnd": 61, + "With": null, + "Top": null, + "HasDistinct": false, + "DistinctOn": null, + "SelectItems": [ + { + "Expr": { + "Object": { + "Fields": [ + { + "Name": "payload", + "QuoteType": 1, + "NamePos": 7, + "NameEnd": 14 + }, + { + "Name": "items", + "QuoteType": 1, + "NamePos": 15, + "NameEnd": 20 + } + ] + }, + "Operation": ".:", + "Index": { + "Name": "Array(JSON)", + "QuoteType": 3, + "NamePos": 23, + "NameEnd": 34 + } + }, + "Modifiers": [], + "Alias": { + "Name": "items", + "QuoteType": 1, + "NamePos": 39, + "NameEnd": 44 + } + } + ], + "From": { + "FromPos": 45, + "Expr": { + "Table": { + "TablePos": 50, + "TableEnd": 61, + "Alias": null, + "Expr": { + "Database": null, + "Table": { + "Name": "source_rows", + "QuoteType": 1, + "NamePos": 50, + "NameEnd": 61 + } + }, + "HasFinal": false + }, + "StatementEnd": 61, + "SampleRatio": null, + "HasFinal": false + } + }, + "Window": null, + "Prewhere": null, + "Where": null, + "GroupBy": null, + "WithTotal": false, + "Having": null, + "OrderBy": null, + "LimitBy": null, + "Limit": null, + "Settings": null, + "Format": null, + "UnionAll": null, + "UnionDistinct": null, + "Except": null, + "Intersect": null + } +] \ No newline at end of file diff --git a/parser/testdata/query/output/select_reserved_keyword_qualifier.sql.golden.json b/parser/testdata/query/output/select_reserved_keyword_qualifier.sql.golden.json new file mode 100644 index 0000000..168bfa9 --- /dev/null +++ b/parser/testdata/query/output/select_reserved_keyword_qualifier.sql.golden.json @@ -0,0 +1,84 @@ +[ + { + "SelectPos": 0, + "StatementEnd": 55, + "With": null, + "Top": null, + "HasDistinct": false, + "DistinctOn": null, + "SelectItems": [ + { + "Expr": { + "Fields": [ + { + "Name": "kill", + "QuoteType": 1, + "NamePos": 7, + "NameEnd": 11 + }, + { + "Name": "item_id", + "QuoteType": 1, + "NamePos": 12, + "NameEnd": 19 + } + ] + }, + "Modifiers": [], + "Alias": { + "Name": "item_id", + "QuoteType": 1, + "NamePos": 23, + "NameEnd": 30 + } + } + ], + "From": { + "FromPos": 31, + "Expr": { + "Table": { + "TablePos": 36, + "TableEnd": 55, + "Alias": null, + "Expr": { + "Expr": { + "Database": null, + "Table": { + "Name": "source_rows", + "QuoteType": 1, + "NamePos": 36, + "NameEnd": 47 + } + }, + "AliasPos": 51, + "Alias": { + "Name": "kill", + "QuoteType": 1, + "NamePos": 51, + "NameEnd": 55 + } + }, + "HasFinal": false + }, + "StatementEnd": 55, + "SampleRatio": null, + "HasFinal": false + } + }, + "Window": null, + "Prewhere": null, + "Where": null, + "GroupBy": null, + "WithTotal": false, + "Having": null, + "OrderBy": null, + "LimitBy": null, + "Limit": null, + "Settings": null, + "Format": null, + "UnionAll": null, + "UnionDistinct": null, + "Except": null, + "Intersect": null + } +] \ No newline at end of file diff --git a/parser/testdata/query/select_dynamic_subcolumn_type_hint.sql b/parser/testdata/query/select_dynamic_subcolumn_type_hint.sql new file mode 100644 index 0000000..1189972 --- /dev/null +++ b/parser/testdata/query/select_dynamic_subcolumn_type_hint.sql @@ -0,0 +1,2 @@ +SELECT payload.items.:`Array(JSON)` AS items +FROM source_rows diff --git a/parser/testdata/query/select_reserved_keyword_qualifier.sql b/parser/testdata/query/select_reserved_keyword_qualifier.sql new file mode 100644 index 0000000..f80f951 --- /dev/null +++ b/parser/testdata/query/select_reserved_keyword_qualifier.sql @@ -0,0 +1,2 @@ +SELECT kill.item_id AS item_id +FROM source_rows AS kill