From ad253b42b101792a0753b60dcca6336038bdf33b Mon Sep 17 00:00:00 2001 From: Denis Gregor Date: Sun, 16 Aug 2026 18:07:09 +0300 Subject: [PATCH] fix: don't match the time zone literal as part of the TZCast keyword The rule was (r"(AT|WITH')\s+TIME\s+ZONE\s+'[^']+'", tokens.Keyword.TZCast) so "AT TIME ZONE 'Asia/Tokyo'" arrived as a single Token.Keyword with the literal inside it. Three things followed from that. The literal was recased along with the keyword, which changes data the caller only asked to have reformatted: >>> sqlparse.format("SELECT ts AT TIME ZONE 'Asia/Tokyo'", keyword_case='upper') "SELECT ts AT TIME ZONE 'ASIA/TOKYO'" '[^']+' cannot express a doubled quote, so AT TIME ZONE 'a''b' was split into a TZCast holding "AT TIME ZONE 'a'" and a stray String token "'b'". The WITH' alternative matches an apostrophe directly after WITH, which is not valid SQL anywhere, so that branch never fired. TIMESTAMP WITH TIME ZONE is tokenized as separate keywords and stays that way. Match only the keyword and leave the literal to the string rule, which already handles doubled quotes. group_tzcasts already accepts a following String.Single in valid_next, so "ts AT TIME ZONE 'Asia/Tokyo'" still groups into one Identifier, aliases and nested calls included. The existing test used 'UTC', which reads the same in either case and contains no quote, so it passed either way. --- CHANGELOG | 7 ++++++- sqlparse/keywords.py | 2 +- tests/test_format.py | 16 ++++++++++++++++ tests/test_tokenize.py | 15 ++++++++++++++- 4 files changed, 37 insertions(+), 3 deletions(-) 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():