Skip to content

Commit 3d103cb

Browse files
committed
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.
1 parent 76222c7 commit 3d103cb

3 files changed

Lines changed: 68 additions & 4 deletions

File tree

changelog.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
Upcoming (TBD)
2+
==============
3+
4+
Features
5+
---------
6+
* Improve completions after operators, by recognizing more operators.
7+
8+
19
1.64.0 (2026/03/13)
210
==============
311

mycli/packages/completion_engine.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,30 @@
1717
re.IGNORECASE,
1818
)
1919

20+
# missing because not binary
21+
# BETWEEN
22+
# CASE
23+
# missing because parens are used
24+
# IN(), and others
25+
# unary operands might need to have another set
26+
# not, !, ~
27+
# arrow operators only take a literal on the right
28+
# and so might need different treatment
29+
# := might also need a different context
30+
# sqlparse would call these identifiers, so they are excluded
31+
# xor
32+
# these are hitting the recursion guard, and so not completing after
33+
# so we might as well leave them out:
34+
# is, 'is not', mod
35+
# sqlparse might also parse "not null" together
36+
# should also verify how sqlparse parses every space-containing case
37+
BINARY_OPERANDS = {
38+
'&', '>', '>>', '>=', '<', '<>', '!=', '<<', '<=', '<=>', '%',
39+
'*', '+', '-', '->', '->>', '/', ':=', '=', '^', 'and', '&&', 'div',
40+
'like', 'not like', 'not regexp', 'or', '||', 'regexp', 'rlike',
41+
'sounds like', '|',
42+
} # fmt: skip
43+
2044

2145
def _enum_value_suggestion(text_before_cursor: str, full_text: str) -> dict[str, Any] | None:
2246
match = _ENUM_VALUE_RE.search(text_before_cursor)
@@ -299,8 +323,6 @@ def suggest_based_on_last_token(
299323
else:
300324
token_v = token.value.lower()
301325

302-
is_operand = lambda x: x and any(x.endswith(op) for op in ["+", "-", "*", "/"]) # noqa: E731
303-
304326
if not token:
305327
return [{"type": "keyword"}, {"type": "special"}]
306328
elif token_v == "*":
@@ -468,11 +490,19 @@ def suggest_based_on_last_token(
468490
elif is_inside_quotes(text_before_cursor, -1) in ['single', 'double']:
469491
return []
470492

471-
elif token_v.endswith(",") or is_operand(token_v) or token_v in ["=", "and", "or"]:
493+
elif token_v.endswith(",") or token_v in BINARY_OPERANDS:
472494
original_text = text_before_cursor
473495
prev_keyword, text_before_cursor = find_prev_keyword(text_before_cursor)
474496
enum_suggestion = _enum_value_suggestion(original_text, full_text)
475-
fallback = suggest_based_on_last_token(prev_keyword, text_before_cursor, None, full_text, identifier) if prev_keyword else []
497+
498+
# guard against non-progressing parser rewinds, which can otherwise
499+
# recurse forever on some operator shapes.
500+
if prev_keyword and text_before_cursor.rstrip() != original_text.rstrip():
501+
fallback = suggest_based_on_last_token(prev_keyword, text_before_cursor, None, full_text, identifier)
502+
else:
503+
# perhaps this fallback should include columns
504+
fallback = [{"type": "keyword"}]
505+
476506
if enum_suggestion and _is_where_or_having(prev_keyword):
477507
return [enum_suggestion] + fallback
478508
return fallback

test/test_completion_engine.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# type: ignore
22

3+
from unittest.mock import patch
4+
35
import pytest
6+
import sqlparse
47

58
from mycli.packages import special
69
from mycli.packages.completion_engine import (
@@ -114,6 +117,27 @@ def test_operand_inside_function_suggests_cols2():
114117
assert suggestion == [{"type": "column", "tables": [(None, "tbl", None)]}]
115118

116119

120+
def test_operand_inside_function_suggests_cols3():
121+
suggestion = suggest_type("SELECT MAX(col1 || FROM tbl", "SELECT MAX(col1 || ")
122+
assert suggestion == [{"type": "column", "tables": [(None, "tbl", None)]}]
123+
124+
125+
def test_operand_inside_function_suggests_cols4():
126+
suggestion = suggest_type("SELECT MAX(col1 LIKE FROM tbl", "SELECT MAX(col1 LIKE ")
127+
assert suggestion == [{"type": "column", "tables": [(None, "tbl", None)]}]
128+
129+
130+
def test_operand_inside_function_suggests_cols5():
131+
suggestion = suggest_type("SELECT MAX(col1 DIV FROM tbl", "SELECT MAX(col1 DIV ")
132+
assert suggestion == [{"type": "column", "tables": [(None, "tbl", None)]}]
133+
134+
135+
@pytest.mark.xfail
136+
def test_arrow_op_inside_function_suggests_nothing():
137+
suggestion = suggest_type("SELECT MAX(col1-> FROM tbl", "SELECT MAX(col1->")
138+
assert suggestion == []
139+
140+
117141
def test_select_suggests_cols_and_funcs():
118142
suggestions = suggest_type("SELECT ", "SELECT ")
119143
assert sorted_dicts(suggestions) == sorted_dicts([
@@ -406,6 +430,8 @@ def test_join_alias_dot_suggests_cols2(sql):
406430
[
407431
"select a.x, b.y from abc a join bcd b on ",
408432
"select a.x, b.y from abc a join bcd b on a.id = b.id OR ",
433+
"select a.x, b.y from abc a join bcd b on a.id = b.id + ",
434+
"select a.x, b.y from abc a join bcd b on a.id = b.id < ",
409435
],
410436
)
411437
def test_on_suggests_aliases(sql):

0 commit comments

Comments
 (0)