Skip to content
Merged
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
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Upcoming (TBD)
Features
--------
* Preserve the query as metadata when saving to Parquet with `.>`.
* Make completion candidate match order configurable.


2.23.0 (2026/09/09)
Expand Down
3 changes: 3 additions & 0 deletions mycli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,10 @@ def __init__(
keyword_casing=keyword_casing,
indexed_column_suffix=indexed_column_suffix,
config_property_names=get_config_property_names(self.config),
completion_match_order=c['main'].as_list('completion_match_order') if 'completion_match_order' in c['main'] else (),
)
for error in self.completer.completion_config_errors:
self.echo(error, err=True, fg='red')
self._completer_lock = threading.Lock()

self.min_completion_trigger = c["main"].as_int("min_completion_trigger")
Expand Down
1 change: 1 addition & 0 deletions mycli/client_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def refresh_completions(self, reset: bool = False) -> list[SQLResult]:
"indexed_column_suffix": self.completer.indexed_column_suffix,
"config_property_names": self.completer.config_property_names,
'frecency_provider': self.completer.frecency_provider,
'completion_match_order': self.completer.completion_match_order,
},
)

Expand Down
11 changes: 11 additions & 0 deletions mycli/myclirc
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ show_warnings = False
# possible completions will be listed.
smart_completion = True

# Methods to find completion candidates, highest priority first. Omitted
# methods follow in the default order; empty uses the default. Filename
# and Polars completions retain their own dedicated ordering.
# * perfect - exact leading match
# * regex - ordered charater match with limited intervening spans ("rgx" matches "regex")
# * under_words - like regex but follows underscores ("uw" matches "under_words")
# * slash_words - like regex but follows slahes ("sw" matches "slash/words")
# * camel_case - like regex but follows capitalization ("cc" matches "CamelCase")
# * rapidfuzz - true approximate matching, _ie_ autcorrect
completion_match_order = perfect, regex, under_words, slash_words, camel_case, rapidfuzz

# Text appended to indexed column names in the completion menu. This text is
# not inserted into the query. Leave empty to disable the marker. Quote values
# containing spaces, commas, or comment characters.
Expand Down
46 changes: 32 additions & 14 deletions mycli/sqlcompleter.py
Original file line number Diff line number Diff line change
Expand Up @@ -955,12 +955,23 @@ def __init__(
indexed_column_suffix: str = '*',
config_property_names: Collection[str] = (),
frecency_provider: Callable[[], Mapping[str, float]] | None = None,
completion_match_order: Collection[str] = (),
) -> None:
super(self.__class__, self).__init__()
self.smart_completion = smart_completion
self.indexed_column_suffix = indexed_column_suffix
self.config_property_names = tuple(sorted(config_property_names))
self.frecency_provider = frecency_provider
self.completion_config_errors: list[str] = []
default_order = tuple(category.name.lower() for category in Fuzziness)
order = tuple(name.strip().lower() for name in completion_match_order if name.strip())
if len(set(order)) != len(order) or any(name not in default_order for name in order):
self.completion_config_errors.append('Invalid completion_match_order; using the default order.')
order = ()
self.completion_match_order = order + tuple(name for name in default_order if name not in order)
self._match_priorities: dict[int, int] = {
Fuzziness[name.upper()]: priority for priority, name in enumerate(self.completion_match_order)
}
self.reserved_words = set()
for x in self.keywords:
self.reserved_words.update(x.split())
Expand Down Expand Up @@ -1313,16 +1324,17 @@ def find_fuzzy_match(
under_words_text: list[str],
case_words_text: list[str],
) -> int | None:
if pattern.search(item.lower()):
return Fuzziness.REGEX

under_words_item = [x for x in item.lower().split('_') if x]
if self.word_parts_match(under_words_text, under_words_item):
return Fuzziness.UNDER_WORDS

case_words_item = re.split(_CASE_CHANGE_PAT, item)
if self.word_parts_match(case_words_text, case_words_item):
return Fuzziness.CAMEL_CASE
for name in self.completion_match_order:
if name == 'regex' and pattern.search(item.lower()):
return Fuzziness.REGEX
if name == 'under_words':
under_words_item = [x for x in item.lower().split('_') if x]
if self.word_parts_match(under_words_text, under_words_item):
return Fuzziness.UNDER_WORDS
if name == 'camel_case':
case_words_item = re.split(_CASE_CHANGE_PAT, item)
if self.word_parts_match(case_words_text, case_words_item):
return Fuzziness.CAMEL_CASE

return None

Expand Down Expand Up @@ -1354,10 +1366,16 @@ def find_fuzzy_matches(
limit=20,
score_cutoff=75,
)
existing = {c[0] for c in completions}
existing = {c[0]: index for index, c in enumerate(completions)}
for item, _score, _type in rapidfuzz_matches:
if len(item) < len(text) / 1.5 or item in existing:
if len(item) < len(text) / 1.5:
continue
if item in existing:
index = existing[item]
if self._match_priorities[Fuzziness.RAPIDFUZZ] < self._match_priorities[completions[index][1]]:
completions[index] = (item, Fuzziness.RAPIDFUZZ)
continue
existing[item] = len(completions)
completions.append((item, Fuzziness.RAPIDFUZZ))

return completions
Expand Down Expand Up @@ -1835,7 +1853,7 @@ def get_completions(
]
break

def completion_sort_key(item: tuple[str, int, int], text_for_len: str):
def completion_sort_key(item: tuple[str, int, int], text_for_len: str) -> tuple[int, int, float, int]:
candidate, fuzziness, rank = item
candidate_frecency = frecency_score(candidate, frecency) if frecency else 0.0
if not text_for_len:
Expand All @@ -1846,7 +1864,7 @@ def completion_sort_key(item: tuple[str, int, int], text_for_len: str):
return (0, 0, -candidate_frecency, -1000 + len(candidate))
# Sort by fuzziness, rank, and frecency.
# todo add alpha here, or original order?
return (fuzziness, rank, -candidate_frecency, 0)
return (self._match_priorities[fuzziness], rank, -candidate_frecency, 0)

if rigid_sort:
uniq_completions_str = dict.fromkeys(x[0] for x in completions)
Expand Down
11 changes: 11 additions & 0 deletions test/myclirc
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ show_warnings = False
# possible completions will be listed.
smart_completion = True

# Methods to find completion candidates, highest priority first. Omitted
# methods follow in the default order; empty uses the default. Filename
# and Polars completions retain their own dedicated ordering.
# * perfect - exact leading match
# * regex - ordered charater match with limited intervening spans ("rgx" matches "regex")
# * under_words - like regex but follows underscores ("uw" matches "under_words")
# * slash_words - like regex but follows slahes ("sw" matches "slash/words")
# * camel_case - like regex but follows capitalization ("cc" matches "CamelCase")
# * rapidfuzz - true approximate matching, _ie_ autcorrect
completion_match_order = perfect, regex, under_words, slash_words, camel_case, rapidfuzz

# Text appended to indexed column names in the completion menu. This text is
# not inserted into the query. Leave empty to disable the marker. Quote values
# containing spaces, commas, or comment characters.
Expand Down
31 changes: 31 additions & 0 deletions test/pytests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,37 @@ def patch_constructor_side_effects(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(client_module, 'get_mylogin_cnf_path', lambda: None)


def test_init_configures_completion_ranking(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
patch_constructor_side_effects(monkeypatch)
myclirc = write_myclirc(tmp_path, '[main]\ncompletion_match_order = CAMEL_CASE, under_words\n')

cli = MyCli(myclirc=myclirc)

assert cli.completer.completion_match_order == ('camel_case', 'under_words', 'perfect', 'regex', 'slash_words', 'rapidfuzz')


@pytest.mark.parametrize('value', ['', 'rapidfuzz'])
def test_init_reads_empty_or_single_match_order(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, value: str) -> None:
patch_constructor_side_effects(monkeypatch)
myclirc = write_myclirc(tmp_path, f'[main]\ncompletion_match_order = {value}\n')

cli = MyCli(myclirc=myclirc)

assert cli.completer.completion_match_order[0] == (value or 'perfect')


def test_init_reports_invalid_completion_match_order(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
patch_constructor_side_effects(monkeypatch)
messages: list[str] = []
monkeypatch.setattr(MyCli, 'echo', lambda self, message, **kwargs: messages.append(message))
myclirc = write_myclirc(tmp_path, '[main]\ncompletion_match_order = invalid\n')

cli = MyCli(myclirc=myclirc)

assert cli.completer.completion_match_order == ('perfect', 'regex', 'under_words', 'slash_words', 'camel_case', 'rapidfuzz')
assert messages == ['Invalid completion_match_order; using the default order.']


def test_init_reports_invalid_ssl_mode(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
patch_constructor_side_effects(monkeypatch)
echo_calls: list[tuple[str, dict[str, Any]]] = []
Expand Down
4 changes: 4 additions & 0 deletions test/pytests/test_client_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def make_refresh_cli() -> tuple[Any, dict[str, Any]]:
cli.completer = SimpleNamespace(
config_property_names=('main.show_warnings',),
frecency_provider=lambda: {'select': 1.0},
completion_match_order=('under_words', 'regex'),
keyword_casing='upper',
indexed_column_suffix=' [indexed]',
set_dbname=lambda dbname: state['set_dbname_calls'].append(dbname),
Expand Down Expand Up @@ -74,6 +75,7 @@ def test_refresh_completions_passes_options_to_refresher() -> None:
'indexed_column_suffix': ' [indexed]',
'config_property_names': ('main.show_warnings',),
'frecency_provider': cli.completer.frecency_provider,
'completion_match_order': ('under_words', 'regex'),
},
)
]
Expand Down Expand Up @@ -104,6 +106,7 @@ def test_refresh_completions_updates_dbname_when_reset() -> None:
cli.completer = SimpleNamespace(
config_property_names=(),
frecency_provider=None,
completion_match_order=(),
keyword_casing='lower',
indexed_column_suffix='*',
set_dbname=lambda dbname: set_dbname_calls.append(dbname),
Expand All @@ -125,6 +128,7 @@ def test_refresh_completions_uses_lock_when_reset() -> None:
cli.completer = SimpleNamespace(
config_property_names=(),
frecency_provider=None,
completion_match_order=(),
keyword_casing='lower',
indexed_column_suffix='*',
set_dbname=lambda dbname: None,
Expand Down
59 changes: 59 additions & 0 deletions test/pytests/test_sqlcompleter.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,65 @@ def provider() -> dict[str, float]:
assert completer.frecency_provider is provider


@pytest.mark.parametrize('order', [(), ('',), ('invalid',), ('regex', 'REGEX')])
def test_completion_match_order_defaults_and_validation(order: tuple[str, ...]) -> None:
completer = SQLCompleter(completion_match_order=order)

assert completer.completion_match_order == tuple(category.name.lower() for category in Fuzziness)
assert bool(completer.completion_config_errors) == (order in [('invalid',), ('regex', 'REGEX')])


def test_completion_match_order_normalizes_partial_list() -> None:
completer = SQLCompleter(completion_match_order=(' CAMEL_CASE ', 'under_words'))

assert completer.completion_match_order == ('camel_case', 'under_words', 'perfect', 'regex', 'slash_words', 'rapidfuzz')


@pytest.mark.parametrize('preferred', ['regex', 'under_words', 'camel_case'])
def test_overlapping_matches_use_configured_priority(preferred: str) -> None:
completer = SQLCompleter(completion_match_order=(preferred,))

matches = list(completer.find_matches('al', ['alphabet']))

assert matches == [('alphabet', Fuzziness[preferred.upper()])]


def test_rapidfuzz_can_replace_an_overlapping_category(monkeypatch: pytest.MonkeyPatch) -> None:
completer = SQLCompleter(completion_match_order=('rapidfuzz',))
monkeypatch.setattr(
mycli.sqlcompleter.rapidfuzz.process,
'extract',
lambda *args, **kwargs: [('alphabet', 100, 0)],
)

assert list(completer.find_matches('alph', ['alphabet'])) == [('alphabet', Fuzziness.RAPIDFUZZ)]


def test_prefix_priority_precedes_frecency(monkeypatch: pytest.MonkeyPatch) -> None:
completer = make_completer(completion_match_order=('rapidfuzz',), frecency_provider=lambda: {'alpha': 100.0})
monkeypatch.setattr(mycli.sqlcompleter, 'suggest_type', lambda *args: [{'type': 'keyword'}])
monkeypatch.setattr(completer, 'find_matches', lambda *args, **kwargs: [('alpha', Fuzziness.RAPIDFUZZ), ('prefix', Fuzziness.REGEX)])

assert [c.text for c in completer.get_completions(Document('pre'), None)] == ['prefix', 'alpha']


def test_custom_match_priority_sorts_candidates(monkeypatch: pytest.MonkeyPatch) -> None:
completer = make_completer(completion_match_order=('under_words',))
monkeypatch.setattr(mycli.sqlcompleter, 'suggest_type', lambda *args: [{'type': 'keyword'}])
monkeypatch.setattr(completer, 'find_matches', lambda *args, **kwargs: [('alpha', Fuzziness.REGEX), ('bravo', Fuzziness.UNDER_WORDS)])

assert [c.text for c in completer.get_completions(Document('x'), None)] == ['bravo', 'alpha']


def test_completion_type_precedes_frecency_for_empty_input(monkeypatch: pytest.MonkeyPatch) -> None:
completer = make_completer(frecency_provider=lambda: {'popular': 10.0})
completer.keywords = ['popular']
completer.functions = ['other']
monkeypatch.setattr(mycli.sqlcompleter, 'suggest_type', lambda *args: [{'type': 'function', 'schema': None}, {'type': 'keyword'}])

assert [c.text for c in completer.get_completions(Document(''), None)] == ['OTHER', 'POPULAR']


def test_special_command_completion_displays_snippet(monkeypatch) -> None:
completer = make_completer()
favorite = mycli.sqlcompleter.SPECIAL_COMMANDS['/favorite']
Expand Down
Loading