diff --git a/CHANGELOG b/CHANGELOG index 44d5938e..9153da3d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,7 +1,12 @@ Development Version ------------------- -Nothing yet. +Bug Fixes + +* The time zone literal is no longer matched as part of the `AT TIME ZONE` + keyword. It was recased along with the keyword, so + `format("SELECT ts AT TIME ZONE 'Asia/Tokyo'", keyword_case='upper')` + returned `'ASIA/TOKYO'`, and a doubled quote in the literal split the token. Release 0.6.0 (Aug 13, 2026) diff --git a/sqlparse/keywords.py b/sqlparse/keywords.py index dd6e5d15..73758b12 100644 --- a/sqlparse/keywords.py +++ b/sqlparse/keywords.py @@ -190,7 +190,7 @@ def find_delimited_spans(text): (r'(LATERAL\s+VIEW\s+)' r'(EXPLODE|INLINE|PARSE_URL_TUPLE|POSEXPLODE|STACK)\b', tokens.Keyword), - (r"(AT|WITH')\s+TIME\s+ZONE\s+'[^']+'", tokens.Keyword.TZCast), + (r"AT\s+TIME\s+ZONE\b", tokens.Keyword.TZCast), (r'(NOT\s+)?(LIKE|ILIKE|RLIKE)\b', tokens.Operator.Comparison), (r'(NOT\s+)?(REGEXP)(\s+(BINARY))?\b', tokens.Operator.Comparison), # Check for keywords, also returns tokens.Name if regex matches diff --git a/tests/test_format.py b/tests/test_format.py index 93495067..d2d3e334 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -14,6 +14,22 @@ def test_keywordcase(self): res = sqlparse.format(sql.upper(), keyword_case='lower') assert res == 'select * from BAR; -- SELECT FOO\n' + def test_keywordcase_leaves_tz_literal_alone(self): + # The TZCast rule matches the time zone literal as part of the keyword + # token, so recasing the token used to rewrite the literal too. 'UTC' is + # the only zone the existing tests use, and it survives either case. + sql = "SELECT ts AT TIME ZONE 'Asia/Tokyo'" + assert sqlparse.format(sql, keyword_case='upper') == sql + assert sqlparse.format(sql, keyword_case='lower') == \ + "select ts at time zone 'Asia/Tokyo'" + assert sqlparse.format(sql, keyword_case='capitalize') == \ + "Select ts At time zone 'Asia/Tokyo'" + + sql = "SELECT ts AT TIME ZONE 'America/New_York'" + assert sqlparse.format(sql, keyword_case='upper') == sql + assert sqlparse.format(sql, keyword_case='lower') == \ + "select ts at time zone 'America/New_York'" + def test_keywordcase_invalid_option(self): sql = 'select * from bar; -- select foo\n' with pytest.raises(SQLParseError): diff --git a/tests/test_tokenize.py b/tests/test_tokenize.py index e76cb29c..71ad21c6 100644 --- a/tests/test_tokenize.py +++ b/tests/test_tokenize.py @@ -234,11 +234,24 @@ def test_near_like_and_ilike_parsed_appropriately(s): @pytest.mark.parametrize('s', ( 'AT TIME ZONE \'UTC\'', + 'AT TIME ZONE \'Asia/Tokyo\'', + 'AT TIME ZONE \'a\'\'b\'', )) def test_parse_tzcast(s): + # The keyword and the time zone literal are separate tokens: the literal is + # data, so it has to stay a String for the filters to leave it alone. p = sqlparse.parse(s)[0] - assert len(p.tokens) == 1 assert p.tokens[0].ttype == T.Keyword.TZCast + assert p.tokens[0].value == 'AT TIME ZONE' + assert p.tokens[-1].ttype == T.String.Single + assert str(p) == s + + +def test_parse_tzcast_groups_with_its_operand(): + p = sqlparse.parse("SELECT ts AT TIME ZONE 'Asia/Tokyo'")[0] + identifier = p.tokens[-1] + assert isinstance(identifier, sql.Identifier) + assert str(identifier) == "ts AT TIME ZONE 'Asia/Tokyo'" def test_cli_commands():