From 8e9d823da2d10ce3613ce2497c9116267e3fb1c1 Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Sat, 14 Mar 2026 12:32:53 -0400 Subject: [PATCH] extend the parser's list of binary operators This is still not perfect, and there is more to do, since as the commentary notes * unary operators are excluded for now * assignment is used differently, but is included * arrow operators should expect a literal on the RHS * BETWEEN and CASE WHEN are more complex to handle in the same way * IS and some other binary operators currently cause an infinite loop, which we catch, but then get generic completions But, this still improves our recognition of operators in context. Operators taken from * https://dev.mysql.com/doc/refman/9.6/en/built-in-function-reference.html One xfailed test is included for the arrow-operator case. --- changelog.md | 1 + mycli/packages/completion_engine.py | 38 ++++++++++++++++++++++++++--- test/test_completion_engine.py | 23 +++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/changelog.md b/changelog.md index afef337e..480a0285 100644 --- a/changelog.md +++ b/changelog.md @@ -10,6 +10,7 @@ Features Bug Fixes --------- * Suppress warnings when `sqlglotrs` is installed. +* Improve completions after operators, by recognizing more operators. 1.64.0 (2026/03/13) diff --git a/mycli/packages/completion_engine.py b/mycli/packages/completion_engine.py index c03a3326..845b4d0e 100644 --- a/mycli/packages/completion_engine.py +++ b/mycli/packages/completion_engine.py @@ -17,6 +17,30 @@ re.IGNORECASE, ) +# missing because not binary +# BETWEEN +# CASE +# missing because parens are used +# IN(), and others +# unary operands might need to have another set +# not, !, ~ +# arrow operators only take a literal on the right +# and so might need different treatment +# := might also need a different context +# sqlparse would call these identifiers, so they are excluded +# xor +# these are hitting the recursion guard, and so not completing after +# so we might as well leave them out: +# is, 'is not', mod +# sqlparse might also parse "not null" together +# should also verify how sqlparse parses every space-containing case +BINARY_OPERANDS = { + '&', '>', '>>', '>=', '<', '<>', '!=', '<<', '<=', '<=>', '%', + '*', '+', '-', '->', '->>', '/', ':=', '=', '^', 'and', '&&', 'div', + 'like', 'not like', 'not regexp', 'or', '||', 'regexp', 'rlike', + 'sounds like', '|', +} # fmt: skip + def _enum_value_suggestion(text_before_cursor: str, full_text: str) -> dict[str, Any] | None: match = _ENUM_VALUE_RE.search(text_before_cursor) @@ -333,8 +357,6 @@ def suggest_based_on_last_token( else: token_v = token.value.lower() - is_operand = lambda x: x and any(x.endswith(op) for op in ["+", "-", "*", "/"]) # noqa: E731 - if not token: return [{"type": "keyword"}, {"type": "special"}] @@ -512,11 +534,19 @@ def suggest_based_on_last_token( elif is_inside_quotes(text_before_cursor, -1) in ['single', 'double']: return [] - elif token_v.endswith(",") or is_operand(token_v) or token_v in ["=", "and", "or"]: + elif token_v.endswith(",") or token_v in BINARY_OPERANDS: original_text = text_before_cursor prev_keyword, text_before_cursor = find_prev_keyword(text_before_cursor) enum_suggestion = _enum_value_suggestion(original_text, full_text) - fallback = suggest_based_on_last_token(prev_keyword, text_before_cursor, None, full_text, identifier) if prev_keyword else [] + + # guard against non-progressing parser rewinds, which can otherwise + # recurse forever on some operator shapes. + if prev_keyword and text_before_cursor.rstrip() != original_text.rstrip(): + fallback = suggest_based_on_last_token(prev_keyword, text_before_cursor, None, full_text, identifier) + else: + # perhaps this fallback should include columns + fallback = [{"type": "keyword"}] + if enum_suggestion and _is_where_or_having(prev_keyword): return [enum_suggestion] + fallback return fallback diff --git a/test/test_completion_engine.py b/test/test_completion_engine.py index 6c33649b..582ea37c 100644 --- a/test/test_completion_engine.py +++ b/test/test_completion_engine.py @@ -126,6 +126,27 @@ def test_operand_inside_function_suggests_cols2(): assert suggestion == [{"type": "column", "tables": [(None, "tbl", None)]}] +def test_operand_inside_function_suggests_cols3(): + suggestion = suggest_type("SELECT MAX(col1 || FROM tbl", "SELECT MAX(col1 || ") + assert suggestion == [{"type": "column", "tables": [(None, "tbl", None)]}] + + +def test_operand_inside_function_suggests_cols4(): + suggestion = suggest_type("SELECT MAX(col1 LIKE FROM tbl", "SELECT MAX(col1 LIKE ") + assert suggestion == [{"type": "column", "tables": [(None, "tbl", None)]}] + + +def test_operand_inside_function_suggests_cols5(): + suggestion = suggest_type("SELECT MAX(col1 DIV FROM tbl", "SELECT MAX(col1 DIV ") + assert suggestion == [{"type": "column", "tables": [(None, "tbl", None)]}] + + +@pytest.mark.xfail +def test_arrow_op_inside_function_suggests_nothing(): + suggestion = suggest_type("SELECT MAX(col1-> FROM tbl", "SELECT MAX(col1->") + assert suggestion == [] + + def test_select_suggests_cols_and_funcs(): suggestions = suggest_type("SELECT ", "SELECT ") assert sorted_dicts(suggestions) == sorted_dicts([ @@ -418,6 +439,8 @@ def test_join_alias_dot_suggests_cols2(sql): [ "select a.x, b.y from abc a join bcd b on ", "select a.x, b.y from abc a join bcd b on a.id = b.id OR ", + "select a.x, b.y from abc a join bcd b on a.id = b.id + ", + "select a.x, b.y from abc a join bcd b on a.id = b.id < ", ], ) def test_on_suggests_aliases(sql):