diff --git a/CHANGELOG b/CHANGELOG index 44d5938e..7acb6dc4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,7 +1,8 @@ Development Version ------------------- -Nothing yet. +* Keep WITH/WITHOUT TIME ZONE suffixes in datetime typecasts grouped with + their expressions, fixing SELECT list indentation (issue811). Release 0.6.0 (Aug 13, 2026) diff --git a/sqlparse/engine/grouping.py b/sqlparse/engine/grouping.py index d8cfa9e1..3557f1f5 100644 --- a/sqlparse/engine/grouping.py +++ b/sqlparse/engine/grouping.py @@ -105,6 +105,29 @@ def valid(token): return token is not None def post(tlist, pidx, tidx, nidx): + type_idx, token = tlist.token_next(tidx, skip_cm=True) + if isinstance(token, sql.Function): + token = token.token_first() + if isinstance(token, sql.Identifier): + token = token.token_first() + if token is None or token.ttype not in (T.Name, T.Name.Builtin, + T.Keyword) \ + or token.value.upper() not in ('TIME', 'TIMESTAMP'): + return pidx, nidx + + # Precision may be separate when a comment prevents function grouping. + next_idx, next_ = tlist.token_next(type_idx, skip_cm=True) + if isinstance(next_, sql.Parenthesis): + type_idx = next_idx + + # Keep the complete datetime type together, without changing keyword + # types (in particular WITH, which also introduces a CTE). + for matches in (((T.Keyword.CTE, 'WITH'), (T.Keyword, 'WITHOUT')), + ((T.Keyword, 'TIME'),), ((T.Keyword, 'ZONE'),)): + type_idx, token = tlist.token_next(type_idx, skip_cm=True) + if token is None or not any(token.match(*m) for m in matches): + return pidx, nidx + nidx = type_idx return pidx, nidx valid_prev = valid_next = valid diff --git a/sqlparse/sql.py b/sqlparse/sql.py index ec44a6da..4f2cdeb6 100644 --- a/sqlparse/sql.py +++ b/sqlparse/sql.py @@ -36,6 +36,24 @@ def get_alias(self): # "name alias" or "complicated column expression alias" _, ws = self.token_next_by(t=T.Whitespace) if len(self.tokens) > 2 and ws is not None: + # A datetime cast's suffix contains whitespace, but no alias. + _, cast = self.token_next_by(m=(T.Punctuation, '::')) + if cast is not None: + idx = len(self.tokens) + idx, token = self.token_prev(idx, skip_cm=True) + while isinstance(token, SquareBrackets): + idx, token = self.token_prev(idx, skip_cm=True) + _, prev = self.token_prev(idx, skip_cm=True) + if prev is not None and prev.match(T.Punctuation, '::'): + return None + idx += 1 + for values in (('ZONE',), ('TIME',), ('WITH', 'WITHOUT')): + idx, token = self.token_prev(idx, skip_cm=True) + if token is None or not token.is_keyword \ + or token.normalized not in values: + break + else: + return None return self._get_first_name(reverse=True) diff --git a/tests/test_grouping.py b/tests/test_grouping.py index 20fab9b7..71ded21b 100644 --- a/tests/test_grouping.py +++ b/tests/test_grouping.py @@ -310,6 +310,104 @@ def test_grouping_typecast(sql, expected): assert p.tokens[2].get_typecast() == expected +@pytest.mark.parametrize('datatype', [ + 'timestamp WITH TIME ZONE', + 'timestamp WITHOUT TIME ZONE', + 'time WITH TIME ZONE', + 'time WITHOUT TIME ZONE', + 'TIMESTAMP(3) WITH TIME ZONE', + 'timestamp (3) WITH TIME ZONE', + 'TIMESTAMP\n(3) WITHOUT TIME ZONE', + 'time (0) WITHOUT TIME ZONE', + 'TiMeStAmP(6) WiTh TiMe ZoNe', + 'timestamp /* precision */ (3) WITH TIME ZONE', + '/* type */ timestamp /* zone */ WITH /* time */ TIME /* end */ ZONE', + 'time -- precision\n(0) WITHOUT -- zone\nTIME ZONE', +]) +@pytest.mark.parametrize('alias', [' AS moment', ' moment', '']) +def test_grouping_datetime_typecast(datatype, alias): + expression = f'ts::{datatype}{alias}' + text = f'SELECT {expression}, other FROM events' + parsed = sqlparse.parse(text)[0] + assert str(parsed) == text + assert isinstance(parsed[2], sql.IdentifierList) + identifiers = list(parsed[2].get_identifiers()) + assert [str(token) for token in identifiers] == [expression, 'other'] + assert identifiers[0].get_alias() == ('moment' if alias else None) + assert [(token.ttype, token.value) for token in parsed.flatten()] == list( + sqlparse.lexer.tokenize(text)) + + +@pytest.mark.parametrize('datatype', [ + 'timestamp', 'timestamp (3)', 'TIMESTAMP\n(3)', + 'time', 'timestamptz', 'custom_type', 'schema.timestamp', + '"timestamp"', '"time"', '"schema"."timestamp"', + 'schema.timestamp(3)', +]) +def test_grouping_datetime_typecast_custom_types(datatype): + text = f'SELECT ts::{datatype} AS moment, other FROM events' + parsed = sqlparse.parse(text)[0] + assert str(parsed) == text + assert isinstance(parsed[2], sql.IdentifierList) + identifiers = list(parsed[2].get_identifiers()) + assert [str(token) for token in identifiers] == [ + f'ts::{datatype} AS moment', 'other'] + assert identifiers[0].get_alias() == 'moment' + + +def test_grouping_datetime_typecast_cte(): + text = ('WITH moments AS (SELECT ts::timestamp WITH TIME ZONE AS moment, ' + 'other FROM events) SELECT moment, other FROM moments') + parsed = sqlparse.parse(text)[0] + assert str(parsed) == text + assert parsed[0].ttype is T.Keyword.CTE + assert parsed.get_type() == 'SELECT' + cte = parsed[2] + assert cte.get_name() == 'moments' + projection = cte[-1][3] + assert isinstance(projection, sql.IdentifierList) + assert [token.get_name() for token in projection.get_identifiers()] == [ + 'moment', 'other'] + + +def test_grouping_datetime_typecast_timezone_conversion(): + expression = "ts::timestamp WITH TIME ZONE AT TIME ZONE 'UTC' AS moment" + text = f'SELECT {expression}, other FROM events' + parsed = sqlparse.parse(text)[0] + assert str(parsed) == text + assert isinstance(parsed[2], sql.IdentifierList) + identifiers = list(parsed[2].get_identifiers()) + assert [str(token) for token in identifiers] == [expression, 'other'] + assert identifiers[0].get_alias() == 'moment' + assert [(token.ttype, token.value) for token in parsed.flatten()] == list( + sqlparse.lexer.tokenize(text)) + + +@pytest.mark.parametrize('expression, alias', [ + ('events.ts::timestamp WITH TIME ZONE /* note */', None), + ("'2020-01-01'::timestamp(6) WITHOUT TIME ZONE", None), + ('ts::timestamp WITH TIME ZONE::text', None), + ('ts::timestamp WITH TIME ZONE::custom_type', None), + ('ts::timestamp WITH TIME ZONE::varchar(3)', None), + ('ts::timestamp WITH TIME ZONE::pg_catalog.text', None), + ('ts::timestamp WITH TIME ZONE[]', None), + ('ts::timestamp WITH TIME ZONE::text[]', None), + ('ts::timestamp WITH TIME ZONE AS zone', 'zone'), + ('ts::timestamp WITH TIME ZONE "Local moment"', 'Local moment'), + ('ts::timestamp WITH TIME ZONE AS "Local moment"', 'Local moment'), + ('ts::timestamp WITH TIME ZONE::text moment', 'moment'), + ('ts::timestamp WITH TIME ZONE::varchar(3) moment', 'moment'), + ('ts::timestamp WITH TIME ZONE::pg_catalog.text AS "Local moment"', + 'Local moment'), +]) +def test_grouping_datetime_typecast_alias_boundaries(expression, alias): + text = f'SELECT {expression}' + parsed = sqlparse.parse(text)[0] + assert str(parsed) == text + assert isinstance(parsed[2], sql.Identifier) + assert parsed[2].get_alias() == alias + + def test_grouping_alias(): s = 'select foo as bar from mytable' p = sqlparse.parse(s)[0] diff --git a/tests/test_regressions.py b/tests/test_regressions.py index aca7f7b3..aa81db17 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -414,6 +414,15 @@ def test_issue562_tzcasts(): 'SELECT f(HOUR\n from bar AT TIME ZONE \'UTC\')\nfrom foo' +def test_issue811_datetime_typecast_reindent(): + text = ("SELECT '2020-01-01'::timestamp WITH TIME ZONE AS moment, " + 'ARRAY(SELECT 1) AS items;') + assert sqlparse.format(text, reindent=True) == ( + "SELECT '2020-01-01'::timestamp WITH TIME ZONE AS moment,\n" + ' ARRAY\n' + ' (SELECT 1) AS items;') + + def test_as_in_parentheses_indents(): # did raise NoneType has no attribute is_group in _process_parentheses formatted = sqlparse.format('(as foo)', reindent=True)