diff --git a/CHANGELOG b/CHANGELOG index 0e5958d8..3a01c0c5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -41,6 +41,10 @@ Bug Fixes * Recognize ``MATERIALIZED`` as a keyword so it is parsed and formatted consistently in ``CREATE MATERIALIZED VIEW`` statements (issue752). * Fix ``get_real_name`` for names with more than two dotted parts (issue332). +* Keep a keyword such as ``BETWEEN`` from being reclassified as an identifier + 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). Release 0.5.5 (Dec 19, 2025) diff --git a/sqlparse/keywords.py b/sqlparse/keywords.py index 586487d4..d12cc226 100644 --- a/sqlparse/keywords.py +++ b/sqlparse/keywords.py @@ -49,7 +49,11 @@ # see issue #39 # Spaces around period `schema . name` are valid identifier # TODO: Spaces before period not implemented - (r'[A-ZÀ-Ü]\w*(?=\s*\.)', tokens.Name), # 'Name'. + # The negative lookahead ``(?!\d)`` keeps a following floating point + # literal such as ``.03`` from being mistaken for a member access, so a + # preceding keyword (e.g. ``BETWEEN``) is not reclassified as a name. + # See issue #601. + (r'[A-ZÀ-Ü]\w*(?=\s*\.(?!\d))', tokens.Name), # 'Name'. # FIXME(atronah): never match, # because `re.match` doesn't work with look-behind regexp feature (r'(?<=\.)[A-ZÀ-Ü]\w*', tokens.Name), # .'Name' diff --git a/tests/test_regressions.py b/tests/test_regressions.py index a4629378..aca7f7b3 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -477,6 +477,34 @@ def test_materialized_view_issue752(): assert formatted == 'CREATE MATERIALIZED VIEW v AS SELECT 1' +@pytest.mark.parametrize('sql', [ + 'a BETWEEN .03 AND .06', + 'a between .03 and .06', +]) +def test_between_leading_dot_float_issue601(sql): + # A keyword followed by a float literal written without a leading zero + # (e.g. ``.03``) must not be reclassified as an identifier because of the + # following period. BETWEEN has to stay a keyword and the literals have to + # remain standalone number tokens. + p = sqlparse.parse(sql)[0] + kw = p.token_next_by(m=(T.Keyword, 'BETWEEN'))[1] + assert kw is not None + floats = list(p.flatten()) + floats = [t for t in floats if t.ttype is T.Number.Float] + assert [t.value for t in floats] == ['.03', '.06'] + # 'and' must remain a keyword too, not be absorbed into an identifier. + assert p.token_next_by(m=(T.Keyword, 'AND'))[1] is not None + + +def test_keyword_before_qualified_name_still_grouped(): + # Regression guard for issue #601 fix: member access such as + # ``schema.name`` (with or without surrounding spaces) must keep working. + for stmt in ('select foo.bar', 'select foo . bar', 'select a.b.c'): + p = sqlparse.parse(stmt)[0] + ident = p.token_next_by(i=sql.Identifier)[1] + assert ident is not None + + @pytest.fixture def limit_recursion(): curr_limit = sys.getrecursionlimit()