diff --git a/CHANGELOG b/CHANGELOG index 44d5938e..2763d399 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,7 +1,11 @@ Development Version ------------------- -Nothing yet. +Bug Fixes + +* ``Statement.get_type()`` now unwraps a leading parenthesis, so a + parenthesized statement such as ``(SELECT ...) UNION (SELECT ...)`` reports + the keyword inside the parentheses instead of ``UNKNOWN`` (issue727, pr877). Release 0.6.0 (Aug 13, 2026) diff --git a/sqlparse/sql.py b/sqlparse/sql.py index ec44a6da..e96b5509 100644 --- a/sqlparse/sql.py +++ b/sqlparse/sql.py @@ -419,9 +419,22 @@ def get_type(self): isn't a DML or DDL keyword "UNKNOWN" is returned. Whitespaces and comments at the beginning of the statement - are ignored. + are ignored. A leading parenthesis is unwrapped, so parenthesized + statements such as ``(SELECT ...) UNION (SELECT ...)`` are + recognized by the keyword inside the parentheses. """ - token = self.token_first(skip_cm=True) + parent = self + token = parent.token_first(skip_cm=True) + + # Unwrap a leading parenthesis, e.g. "(SELECT ...) UNION (...)" or a + # nested "((SELECT ...))", so the keyword inside determines the type. + while isinstance(token, Parenthesis): + parent = token + # Skip the opening parenthesis punctuation as well as any + # whitespace and comments that follow it. + open_idx = parent.token_index(parent.token_first()) + _, token = parent.token_next(open_idx, skip_cm=True) + if token is None: # An "empty" statement that either has not tokens at all # or only whitespace tokens. @@ -434,11 +447,11 @@ def get_type(self): # The WITH keyword should be followed by either an Identifier or # an IdentifierList containing the CTE definitions; the actual # DML keyword (e.g. SELECT, INSERT) will follow next. - tidx = self.token_index(token) + tidx = parent.token_index(token) while tidx is not None: - tidx, token = self.token_next(tidx, skip_ws=True) + tidx, token = parent.token_next(tidx, skip_ws=True) if isinstance(token, (Identifier, IdentifierList)): - tidx, token = self.token_next(tidx, skip_ws=True) + tidx, token = parent.token_next(tidx, skip_ws=True) if token is not None \ and token.ttype == T.Keyword.DML: diff --git a/tests/test_regressions.py b/tests/test_regressions.py index aca7f7b3..51ccf193 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -516,3 +516,28 @@ def limit_recursion(): def test_max_recursion(limit_recursion): with pytest.raises(SQLParseError): sqlparse.parse('[' * 1000 + ']' * 1000) + + +def test_gettype_parenthesized_statement_issue727(): + # A statement wrapped in parentheses (e.g. one side of a compound + # ``UNION``) must report the keyword found inside the parentheses + # instead of ``UNKNOWN``. + paren_union = sqlparse.parse( + '(select 1 as "a") UNION (select 2 as "a")')[0] + assert paren_union.get_type() == 'SELECT' + + assert sqlparse.parse('(SELECT * FROM foo)')[0].get_type() == 'SELECT' + assert sqlparse.parse(' ( select 1 )')[0].get_type() == 'SELECT' + # Nested parentheses are unwrapped as well. + assert sqlparse.parse('((select 1))')[0].get_type() == 'SELECT' + # The keyword inside the parentheses is what counts, not SELECT. + assert sqlparse.parse( + '(INSERT INTO foo VALUES (1))')[0].get_type() == 'INSERT' + # A leading comment inside the parentheses is skipped. + assert sqlparse.parse( + '( -- comment\n select 1)')[0].get_type() == 'SELECT' + # A CTE wrapped in parentheses still resolves to the DML keyword. + assert sqlparse.parse( + '(WITH foo AS (SELECT 1) SELECT * FROM foo)')[0].get_type() == 'SELECT' + # Empty parentheses have no keyword to report. + assert sqlparse.parse('()')[0].get_type() == 'UNKNOWN'