Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
23 changes: 23 additions & 0 deletions sqlparse/engine/grouping.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions sqlparse/sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
98 changes: 98 additions & 0 deletions tests/test_grouping.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
9 changes: 9 additions & 0 deletions tests/test_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down