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
6 changes: 5 additions & 1 deletion CHANGELOG
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
23 changes: 18 additions & 5 deletions sqlparse/sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions tests/test_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'