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
5 changes: 4 additions & 1 deletion CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
Development Version
-------------------

Nothing yet.
Bug Fixes

* Group ordered-set aggregates with their WITHIN GROUP clause and optional
OVER clause before resolving column aliases (issue700).


Release 0.6.0 (Aug 13, 2026)
Expand Down
6 changes: 6 additions & 0 deletions sqlparse/engine/grouping.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,12 @@ def group_functions(tlist):
nidx, next_ = tlist.token_next(tidx)
if isinstance(next_, sql.Parenthesis):
over_idx, over = tlist.token_next(nidx)
within_idx, within = tlist.token_next(nidx, skip_cm=True)
if within and within.match(T.Keyword, r'WITHIN\s+GROUP', regex=True):
order_idx, order = tlist.token_next(within_idx, skip_cm=True)
if isinstance(order, sql.Parenthesis):
nidx = order_idx
over_idx, over = tlist.token_next(nidx, skip_cm=True)
if over and isinstance(over, sql.Over):
eidx = over_idx
else:
Expand Down
1 change: 1 addition & 0 deletions sqlparse/keywords.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ def find_delimited_spans(text):
(r'UNION\s+ALL\b', tokens.Keyword),
(r'CREATE(\s+OR\s+REPLACE)?\b', tokens.Keyword.DDL),
(r'DOUBLE\s+PRECISION\b', tokens.Name.Builtin),
(r'WITHIN\s+GROUP\b(?!\s+BY\b)', tokens.Keyword),
(r'GROUP\s+BY\b', tokens.Keyword),
(r'ORDER\s+BY\b', tokens.Keyword),
(r'PRIMARY\s+KEY\b', tokens.Keyword),
Expand Down
133 changes: 133 additions & 0 deletions tests/test_within_group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Ordered-set aggregate grouping (issue700)."""
import pytest

import sqlparse
from sqlparse import sql


@pytest.mark.parametrize('aggregate', [
"LISTAGG(attr, ', ')",
'PERCENTILE_CONT(0.5)',
'percentile_disc(0.5)',
])
@pytest.mark.parametrize('clause', [
'WITHIN GROUP(ORDER BY attr)',
'within group (order by attr desc)',
'WITHIN\nGROUP (ORDER BY attr)',
'WITHIN\tGROUP (ORDER BY attr)',
])
def test_ordered_set_aggregate_has_one_alias(aggregate, clause):
expression = f'{aggregate} {clause}'
query = f'SELECT {expression} AS result FROM source'
stmt, = sqlparse.parse(query)
column = stmt.tokens[2]
assert isinstance(column, sql.Identifier)
assert str(column) == expression + ' AS result'
assert column.get_alias() == 'result'
function = column.tokens[0]
assert isinstance(function, sql.Function)
assert str(function) == expression
assert function.get_name().lower() == aggregate.split('(')[0].lower()
assert str(stmt) == query


def test_ordered_set_argument_list_excludes_ordering():
query = "SELECT LISTAGG(attr, ', ') WITHIN GROUP(ORDER BY attr)"
function = sqlparse.parse(query)[0].tokens[2]
assert isinstance(function, sql.Function)
assert [str(p) for p in function.get_parameters()] == ['attr', "', '"]


def test_ordered_set_aggregate_followed_by_window_and_alias():
expression = ('LISTAGG(attr) WITHIN GROUP (ORDER BY attr) '
'OVER (PARTITION BY category)')
stmt = sqlparse.parse(f'SELECT {expression} ordered_value FROM source')[0]
column = stmt.tokens[2]
assert isinstance(column, sql.Identifier)
assert column.get_alias() == 'ordered_value'
assert str(column.tokens[0]) == expression
assert isinstance(column.tokens[0].tokens[-1], sql.Over)


def test_ordered_set_aggregate_in_expression_and_column_list():
query = ('SELECT COALESCE(PERCENTILE_CONT(0.5) '
'WITHIN GROUP (ORDER BY attr), 0) AS p, other FROM source')
stmt = sqlparse.parse(query)[0]
columns = list(stmt.tokens[2].get_identifiers())
assert len(columns) == 2
assert columns[0].get_alias() == 'p'
coalesce = columns[0].tokens[0]
args = list(coalesce.get_parameters())
assert len(args) == 2
assert isinstance(args[0], sql.Function)
assert str(args[0]).endswith('WITHIN GROUP (ORDER BY attr)')
assert str(columns[1]) == 'other'


def test_within_group_keyword_case():
query = 'select percentile_cont(0.5) within group (order by attr)'
assert sqlparse.format(query, keyword_case='upper') == (
'SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY attr)')


@pytest.mark.parametrize('gap1,gap2,gap3', [
(' /* a */ ', ' ', ' '),
(' ', ' /* b */ ', ' '),
(' ', ' ', ' /* c */ '),
(' -- a\n', ' -- b\n', ' -- c\n'),
])
def test_comments_between_aggregate_clauses(gap1, gap2, gap3):
expression = (f'LISTAGG(attr){gap1}WITHIN GROUP{gap2}(ORDER BY attr)'
f'{gap3}OVER (PARTITION BY category)')
stmt = sqlparse.parse(f'SELECT {expression} AS result FROM source')[0]
column = stmt.tokens[2]
assert column.get_alias() == 'result'
assert str(column.tokens[0]) == expression


@pytest.mark.parametrize('query,identifier,group_by', [
('SELECT f(x) within GROUP BY x', 'f(x) within', 'GROUP BY'),
('SELECT x AS within GROUP BY x', 'x AS within', 'GROUP BY'),
('SELECT x FROM src within GROUP BY x', 'src within', 'GROUP BY'),
('SELECT f(x) within\nGROUP\tBY x', 'f(x) within', 'GROUP\tBY'),
])
@pytest.mark.parametrize('case', [None, 'lower', 'swapcase'])
def test_within_alias_before_group_by(query, identifier, group_by, case):
if case:
query = getattr(query, case)()
identifier = getattr(identifier, case)()
group_by = getattr(group_by, case)()
alias = identifier.split()[-1]
lexical = list(sqlparse.lexer.tokenize(query))
assert (sqlparse.tokens.Name, alias) in lexical
assert (sqlparse.tokens.Keyword, group_by) in lexical
assert not any(' '.join(value.upper().split()) == 'WITHIN GROUP'
for _, value in lexical)

stmt, = sqlparse.parse(query)
columns = [token for token in stmt.tokens
if isinstance(token, sql.Identifier)
and token.get_alias() == alias]
assert len(columns) == 1
assert str(columns[0]) == identifier
group_tokens = [token for token in stmt.tokens
if token.ttype is sqlparse.tokens.Keyword
and token.value == group_by]
assert len(group_tokens) == 1
assert stmt.token_next(stmt.token_index(columns[0]))[1] is group_tokens[0]
assert str(stmt) == query


def test_bare_within_remains_an_alias():
column = sqlparse.parse('SELECT f(x) within FROM source')[0].tokens[2]
assert column.get_alias() == 'within'


@pytest.mark.parametrize('query', [
'SELECT f(x) within FROM source',
'SELECT within_group(x) FROM source',
'SELECT f(x), g(y) FROM source',
'SELECT f(x) OVER (PARTITION BY y) FROM source',
])
def test_unrelated_function_syntax_roundtrips(query):
assert str(sqlparse.parse(query)[0]) == query