Skip to content

Commit 8e9d823

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 724182f commit 8e9d823

3 files changed

Lines changed: 58 additions & 4 deletions

File tree

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Features
1010
Bug Fixes
1111
---------
1212
* Suppress warnings when `sqlglotrs` is installed.
13+
* Improve completions after operators, by recognizing more operators.
1314

1415

1516
1.64.0 (2026/03/13)

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)
@@ -333,8 +357,6 @@ def suggest_based_on_last_token(
333357
else:
334358
token_v = token.value.lower()
335359

336-
is_operand = lambda x: x and any(x.endswith(op) for op in ["+", "-", "*", "/"]) # noqa: E731
337-
338360
if not token:
339361
return [{"type": "keyword"}, {"type": "special"}]
340362

@@ -512,11 +534,19 @@ def suggest_based_on_last_token(
512534
elif is_inside_quotes(text_before_cursor, -1) in ['single', 'double']:
513535
return []
514536

515-
elif token_v.endswith(",") or is_operand(token_v) or token_v in ["=", "and", "or"]:
537+
elif token_v.endswith(",") or token_v in BINARY_OPERANDS:
516538
original_text = text_before_cursor
517539
prev_keyword, text_before_cursor = find_prev_keyword(text_before_cursor)
518540
enum_suggestion = _enum_value_suggestion(original_text, full_text)
519-
fallback = suggest_based_on_last_token(prev_keyword, text_before_cursor, None, full_text, identifier) if prev_keyword else []
541+
542+
# guard against non-progressing parser rewinds, which can otherwise
543+
# recurse forever on some operator shapes.
544+
if prev_keyword and text_before_cursor.rstrip() != original_text.rstrip():
545+
fallback = suggest_based_on_last_token(prev_keyword, text_before_cursor, None, full_text, identifier)
546+
else:
547+
# perhaps this fallback should include columns
548+
fallback = [{"type": "keyword"}]
549+
520550
if enum_suggestion and _is_where_or_having(prev_keyword):
521551
return [enum_suggestion] + fallback
522552
return fallback

test/test_completion_engine.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,27 @@ def test_operand_inside_function_suggests_cols2():
126126
assert suggestion == [{"type": "column", "tables": [(None, "tbl", None)]}]
127127

128128

129+
def test_operand_inside_function_suggests_cols3():
130+
suggestion = suggest_type("SELECT MAX(col1 || FROM tbl", "SELECT MAX(col1 || ")
131+
assert suggestion == [{"type": "column", "tables": [(None, "tbl", None)]}]
132+
133+
134+
def test_operand_inside_function_suggests_cols4():
135+
suggestion = suggest_type("SELECT MAX(col1 LIKE FROM tbl", "SELECT MAX(col1 LIKE ")
136+
assert suggestion == [{"type": "column", "tables": [(None, "tbl", None)]}]
137+
138+
139+
def test_operand_inside_function_suggests_cols5():
140+
suggestion = suggest_type("SELECT MAX(col1 DIV FROM tbl", "SELECT MAX(col1 DIV ")
141+
assert suggestion == [{"type": "column", "tables": [(None, "tbl", None)]}]
142+
143+
144+
@pytest.mark.xfail
145+
def test_arrow_op_inside_function_suggests_nothing():
146+
suggestion = suggest_type("SELECT MAX(col1-> FROM tbl", "SELECT MAX(col1->")
147+
assert suggestion == []
148+
149+
129150
def test_select_suggests_cols_and_funcs():
130151
suggestions = suggest_type("SELECT ", "SELECT ")
131152
assert sorted_dicts(suggestions) == sorted_dicts([
@@ -418,6 +439,8 @@ def test_join_alias_dot_suggests_cols2(sql):
418439
[
419440
"select a.x, b.y from abc a join bcd b on ",
420441
"select a.x, b.y from abc a join bcd b on a.id = b.id OR ",
442+
"select a.x, b.y from abc a join bcd b on a.id = b.id + ",
443+
"select a.x, b.y from abc a join bcd b on a.id = b.id < ",
421444
],
422445
)
423446
def test_on_suggests_aliases(sql):

0 commit comments

Comments
 (0)