diff --git a/CHANGELOG b/CHANGELOG index 3a01c0c5..21b516dd 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -45,6 +45,10 @@ Bug Fixes when it is followed by a float literal written without a leading zero (e.g. ``x BETWEEN .03 AND .06``), so the bounds are parsed as number tokens (issue601). +* Group ``ROLE`` as an identifier when it is used as a column name in an + identifier list (e.g. ``SELECT a, role, b``), so it appears alongside the + other columns in ``get_identifiers()``, while keeping it a keyword elsewhere + such as ``CREATE ROLE`` (issue798). Release 0.5.5 (Dec 19, 2025) diff --git a/sqlparse/engine/grouping.py b/sqlparse/engine/grouping.py index a8300918..b3589e5b 100644 --- a/sqlparse/engine/grouping.py +++ b/sqlparse/engine/grouping.py @@ -254,6 +254,27 @@ def group_identifier(tlist): tidx, token = tlist.token_next_by(t=ttypes, idx=tidx) +@recurse(sql.Identifier) +def group_identifier_role(tlist): + # ROLE is a non-reserved keyword, so it may also be a column name. When it + # appears as a member of an identifier list (i.e. flanked by a comma) wrap + # it in an Identifier, matching the neighbouring plain names, so consumers + # such as ``get_identifiers()`` see a uniform Identifier rather than a bare + # keyword token. Outside of a list (e.g. ``CREATE ROLE r``) there is no + # adjacent comma, so it keeps its keyword role. See issue #798. + m_role = T.Keyword, 'ROLE' + tidx, token = tlist.token_next_by(m=m_role) + while token: + _, prev_ = tlist.token_prev(tidx) + _, next_ = tlist.token_next(tidx) + in_list = (prev_ is not None and prev_.match(T.Punctuation, ',')) or ( + next_ is not None and next_.match(T.Punctuation, ',') + ) + if in_list: + tlist.group_tokens(sql.Identifier, tidx, tidx) + tidx, token = tlist.token_next_by(m=m_role, idx=tidx) + + @recurse(sql.Over) def group_over(tlist): tidx, token = tlist.token_next_by(m=sql.Over.M_OPEN) @@ -452,6 +473,7 @@ def group(stmt): group_period, group_arrays, group_identifier, + group_identifier_role, group_order, group_typecasts, group_tzcasts, diff --git a/tests/test_grouping.py b/tests/test_grouping.py index 20fab9b7..9fc55a12 100644 --- a/tests/test_grouping.py +++ b/tests/test_grouping.py @@ -241,6 +241,26 @@ def test_grouping_identifier_list_with_inline_comments(): assert isinstance(p.tokens[0].tokens[3], sql.Identifier) +def test_grouping_identifier_list_with_role(): + # issue798: ROLE is a non-reserved keyword usable as a column name; in an + # identifier list it should be an Identifier like its plain-name peers. + p = sqlparse.parse('SELECT a, role, b FROM t')[0] + assert isinstance(p.tokens[2], sql.IdentifierList) + identifiers = list(p.tokens[2].get_identifiers()) + assert all(isinstance(i, sql.Identifier) for i in identifiers) + assert [i.value for i in identifiers] == ['a', 'role', 'b'] + + +def test_grouping_role_keyword_outside_identifier_list(): + # issue798: ROLE keeps its keyword role outside an identifier list, so e.g. + # CREATE ROLE is not misparsed as an identifier aliased to the role name. + for sql_str in ('CREATE ROLE myrole', 'SET ROLE admin', 'DROP ROLE r1, r2'): + p = sqlparse.parse(sql_str)[0] + role = next(t for t in p.flatten() if t.value.upper() == 'ROLE') + assert role.ttype is T.Keyword + assert not isinstance(role.parent, sql.Identifier) + + def test_grouping_identifiers_with_operators(): p = sqlparse.parse('a+b as c from table where (d-e)%2= 1')[0] assert len([x for x in p.flatten() if x.ttype == T.Name]) == 5