diff --git a/CHANGELOG b/CHANGELOG index 44d5938e..4cbe43b6 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,7 +1,7 @@ Development Version ------------------- -Nothing yet. +* Keep GRANT and REVOKE privilege clauses together when reindenting (issue409). Release 0.6.0 (Aug 13, 2026) diff --git a/sqlparse/filters/reindent.py b/sqlparse/filters/reindent.py index f8f64532..8c468f77 100644 --- a/sqlparse/filters/reindent.py +++ b/sqlparse/filters/reindent.py @@ -276,6 +276,15 @@ def _process_default(self, tlist, stmts=True): for sgroup in tlist.get_sublists(): self._process(sgroup) + def _process_statement(self, tlist): + first = tlist.token_first(skip_cm=True) + if first and first.ttype == T.Keyword.DCL: + # Permission names and REVOKE's FROM are not query boundaries. + for sgroup in tlist.get_sublists(): + self._process(sgroup) + else: + self._process_default(tlist) + def process(self, stmt): self._curr_stmt = stmt self._process(stmt) diff --git a/tests/test_dcl_reindent.py b/tests/test_dcl_reindent.py new file mode 100644 index 00000000..73b035fc --- /dev/null +++ b/tests/test_dcl_reindent.py @@ -0,0 +1,47 @@ +"""Privilege names and REVOKE's FROM are not query-clause boundaries.""" + +import pytest + +import sqlparse +from sqlparse import tokens as T + + +@pytest.mark.parametrize('statement', [ + 'GRANT SELECT ON a_schema.an_object TO a_role;', + 'GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE ON t TO a_role;', + 'GRANT SELECT ON ALL TABLES IN SCHEMA public TO umbrella;', + 'GRANT ALL PRIVILEGES ON TABLE t TO a_role WITH GRANT OPTION;', + 'REVOKE SELECT ON TABLE customers FROM umbrella;', + 'REVOKE GRANT OPTION FOR SELECT, UPDATE ON t FROM a_role;', + 'REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM a_role;', +]) +def test_privileges_are_not_split_as_statements(statement): + formatted = sqlparse.format(statement, reindent=True) + assert formatted == statement + assert sqlparse.format(formatted, reindent=True) == formatted + + +@pytest.mark.parametrize('comment', ['-- privileges\n', '/* privileges */ ']) +def test_dcl_after_comment_is_recognized(comment): + statement = comment + 'GRANT SELECT ON t TO a_role;' + assert sqlparse.format(statement, reindent=True) == statement + + +def test_dcl_retains_keyword_case_filtering(): + statement = 'revoke select on table customers from umbrella;' + assert sqlparse.format(statement, reindent=True, keyword_case='upper') == ( + 'REVOKE SELECT ON TABLE customers FROM umbrella;') + + +def test_dcl_does_not_disable_following_query_indentation(): + statement = 'GRANT SELECT ON t TO a_role; SELECT a, b FROM t;' + assert sqlparse.format(statement, reindent=True) == ( + 'GRANT SELECT ON t TO a_role;\n\nSELECT a,\n b\nFROM t;') + + +def test_privilege_tokens_keep_their_existing_lexical_types(): + statement = sqlparse.parse('GRANT SELECT ON t TO a_role;')[0] + keywords = [(token.value, token.ttype) for token in statement.flatten() + if token.ttype in T.Keyword] + assert keywords == [('GRANT', T.Keyword.DCL), ('SELECT', T.Keyword.DML), + ('ON', T.Keyword), ('TO', T.Keyword)]