From a298de2659567f728f727590fbcb2bae5c1856b7 Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Mon, 28 Sep 2026 16:16:21 -0700 Subject: [PATCH] Fix date-part aliases on SELECT function calls --- CHANGELOG | 3 +- sqlparse/engine/grouping.py | 51 +++++++++++++++ tests/test_grouping.py | 127 ++++++++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 44d5938e..125da45e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,7 +1,8 @@ Development Version ------------------- -Nothing yet. +* Group date-part keyword aliases after bare SELECT function calls without AS + (issue781). Release 0.6.0 (Aug 13, 2026) diff --git a/sqlparse/engine/grouping.py b/sqlparse/engine/grouping.py index d8cfa9e1..9f354e8a 100644 --- a/sqlparse/engine/grouping.py +++ b/sqlparse/engine/grouping.py @@ -254,6 +254,56 @@ def group_identifier(tlist): tidx, token = tlist.token_next_by(t=ttypes, idx=tidx) +@recurse() +def group_select_aliases(tlist): + """Recognize interval keywords used as aliases of SELECT functions.""" + in_select = False + at_start = False + boundaries = (T.Keyword, ('FROM', 'INTO', 'GROUP BY', 'HAVING', + 'ORDER BY', 'LIMIT', 'OFFSET', 'FETCH', 'FOR', + 'WINDOW', 'QUALIFY', 'UNION', 'UNION ALL', + 'INTERSECT', 'EXCEPT', 'MINUS')) + literals = (T.Keyword, ('NULL', 'TRUE', 'FALSE', 'CURRENT_DATE', + 'CURRENT_TIME', 'CURRENT_TIMESTAMP', + 'LOCALTIME', 'LOCALTIMESTAMP')) + for tidx, token in enumerate(tlist.tokens): + if token.is_whitespace or imt(token, i=sql.Comment, t=T.Comment): + continue + if token.match(T.DML, 'SELECT'): + in_select = at_start = True + continue + if not in_select: + continue + if at_start and token.match(T.Keyword, ('ALL', 'DISTINCT')): + continue + # A scalar keyword is still part of the projection; an unrecognized + # keyword may start a dialect-specific clause, so stop there. + if isinstance(token, sql.Where) or (token.is_keyword and not imt( + token, m=[sql.TypedLiteral.M_EXTEND, literals], + )): + in_select = False + continue + if token.match(T.Punctuation, ','): + at_start = True + continue + if (at_start and isinstance(token, sql.Function) + and token.token_first().value.upper() != 'INTERVAL'): + nidx, alias = tlist.token_next(tidx) + if alias is not None and alias.match(*sql.TypedLiteral.M_EXTEND): + _, following = tlist.token_next(nidx, skip_cm=True) + if following is None or imt( + following, i=sql.Where, + m=[(T.Punctuation, (',', ';', ')')), boundaries], + ): + # Only this confirmed alias is a name. In particular, + # INTERVAL f() DAY does not start with a bare function, + # and INTERVAL(amount) is excluded above as well. + name = sql.Identifier([sql.Token(T.Name, alias.value)]) + name.parent = tlist + tlist.tokens[nidx] = name + at_start = False + + @recurse(sql.Over) def group_over(tlist): tidx, token = tlist.token_next_by(m=sql.Over.M_OPEN) @@ -464,6 +514,7 @@ def group(stmt): group_operator, group_comparison, group_as, + group_select_aliases, group_aliased, group_assignment, diff --git a/tests/test_grouping.py b/tests/test_grouping.py index 20fab9b7..02c5ec2c 100644 --- a/tests/test_grouping.py +++ b/tests/test_grouping.py @@ -332,6 +332,133 @@ def test_grouping_alias(): assert p.tokens[4].get_alias() == 'view' +@pytest.mark.parametrize('modifier', ['', 'DISTINCT ', 'ALL ']) +def test_function_keyword_aliases_in_select_list(modifier): + s = (f'SELECT {modifier}EXTRACT(YEAR FROM foo) year, ' + 'EXTRACT(MONTH FROM foo) month, value FROM orders') + p = sqlparse.parse(s)[0] + _, projection = p.token_next_by(i=sql.IdentifierList) + assert projection is not None + identifiers = list(projection.get_identifiers()) + assert [item.get_alias() for item in identifiers] == ['year', 'month', None] + assert [item.get_real_name() for item in identifiers] == ['EXTRACT', 'EXTRACT', 'value'] + assert str(p) == s + + +def test_function_keyword_alias_reindent(): + s = ('select extract(year from foo) year, ' + 'extract(month from foo) month, value from orders') + assert sqlparse.format(s, reindent=True) == '\n'.join([ + 'select extract(year', + ' from foo) year,', + ' extract(month', + ' from foo) month,', + ' value', + 'from orders', + ]) + + +@pytest.mark.parametrize('alias', ['day', 'hour', 'minute', 'month', 'second', 'year']) +@pytest.mark.parametrize('ending', ['', ';', ' FROM orders', ' WHERE foo > 0']) +def test_function_keyword_alias_at_projection_end(alias, ending): + s = f'SELECT EXTRACT(YEAR FROM foo) {alias}{ending}' + p = sqlparse.parse(s)[0] + assert isinstance(p.tokens[2], sql.Identifier) + assert p.tokens[2].get_alias() == alias + assert p.tokens[2].get_real_name() == 'EXTRACT' + assert str(p) == s + + +def test_function_keyword_alias_in_nested_select(): + s = 'SELECT COALESCE((SELECT EXTRACT(YEAR FROM foo) year), 1) month FROM orders' + p = sqlparse.parse(s)[0] + + def aliases(group): + for child in group.get_sublists(): + if isinstance(child, sql.Identifier) and child.get_alias(): + yield child.get_alias() + yield from aliases(child) + + assert list(aliases(p)) == ['month', 'year'] + assert str(p) == s + + +def test_function_keyword_alias_after_explicit_alias(): + s = 'SELECT value AS label, EXTRACT(YEAR FROM foo) year FROM orders' + p = sqlparse.parse(s)[0] + _, projection = p.token_next_by(i=sql.IdentifierList) + assert [item.get_alias() for item in projection.get_identifiers()] == ['label', 'year'] + assert str(p) == s + + +@pytest.mark.parametrize('literal', [ + 'NULL', 'TRUE', 'FALSE', 'CURRENT_DATE', 'CURRENT_TIME', + 'CURRENT_TIMESTAMP', 'LOCALTIME', 'LOCALTIMESTAMP', +]) +def test_function_keyword_alias_after_literal(literal): + s = (f'SELECT {literal}, EXTRACT(YEAR FROM foo) year, ' + 'EXTRACT(MONTH FROM foo) month FROM orders') + p = sqlparse.parse(s)[0] + _, projection = p.token_next_by(i=sql.IdentifierList) + identifiers = list(projection.get_identifiers()) + assert [str(item) for item in identifiers] == [ + literal, 'EXTRACT(YEAR FROM foo) year', 'EXTRACT(MONTH FROM foo) month', + ] + assert [item.get_alias() for item in identifiers[1:]] == ['year', 'month'] + assert str(p) == s + + +def test_function_keyword_alias_keeps_select_clause_boundaries(): + s = ('SELECT EXTRACT(YEAR FROM foo) year FROM orders ' + 'JOIN items ON orders.id = items.id ' + 'UNION SELECT EXTRACT(MONTH FROM foo) month FROM other') + p = sqlparse.parse(s)[0] + assert [t.get_alias() for t in p if isinstance(t, sql.Identifier)] == [ + 'year', None, None, 'month', None, + ] + assert [t.normalized for t in p if t.is_keyword] == [ + 'SELECT', 'FROM', 'JOIN', 'ON', 'UNION', 'SELECT', 'FROM', + ] + assert str(p) == s + + +@pytest.mark.parametrize('expression', [ + 'INTERVAL ABS(-1) DAY', + 'INTERVAL(1) DAY', + 'interval (ABS(-1)) DAY', + 'INTERVAL((1 + 2)) DAY', + 'INTERVAL (EXTRACT(DAY FROM foo)) DAY', + "INTERVAL '1' DAY", + '(CURRENT_TIMESTAMP - CURRENT_TIMESTAMP) DAY TO SECOND', + 'MAX(finished) - MIN(started) DAY TO SECOND', +]) +def test_function_keyword_alias_does_not_consume_interval_units(expression): + s = f'SELECT {expression} FROM jobs' + p = sqlparse.parse(s)[0] + units = [token for token in p.flatten() if token.value in ('DAY', 'SECOND')] + assert units + assert all(token.ttype is T.Keyword for token in units) + assert str(p) == s + + +def test_function_keyword_alias_on_quoted_interval_name(): + s = 'SELECT `INTERVAL`(1) day FROM jobs' + p = sqlparse.parse(s)[0] + assert p.tokens[2].get_alias() == 'day' + assert p.tokens[2].get_real_name() == 'INTERVAL' + assert str(p) == s + + +def test_function_keyword_alias_preserves_window_interval(): + s = ('SELECT MIN(time) OVER (ORDER BY time RANGE ' + "INTERVAL '1' DAY PRECEDING) year FROM jobs") + p = sqlparse.parse(s)[0] + assert isinstance(p.tokens[2], sql.Identifier) + assert p.tokens[2].get_alias() == 'year' + assert [t.ttype for t in p.flatten() if t.value == 'DAY'] == [T.Keyword] + assert str(p) == s + + def test_grouping_alias_case(): # see issue46 p = sqlparse.parse('CASE WHEN 1 THEN 2 ELSE 3 END foo')[0]