diff --git a/changelog.md b/changelog.md index 0b2f1e32..250b4321 100644 --- a/changelog.md +++ b/changelog.md @@ -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) diff --git a/mycli/client.py b/mycli/client.py index 2784bfe1..6ba6abf4 100644 --- a/mycli/client.py +++ b/mycli/client.py @@ -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") diff --git a/mycli/client_query.py b/mycli/client_query.py index 7970f80e..e4a455fe 100644 --- a/mycli/client_query.py +++ b/mycli/client_query.py @@ -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, }, ) diff --git a/mycli/myclirc b/mycli/myclirc index ee546318..947c32a5 100644 --- a/mycli/myclirc +++ b/mycli/myclirc @@ -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. diff --git a/mycli/sqlcompleter.py b/mycli/sqlcompleter.py index a039209e..89409cfa 100644 --- a/mycli/sqlcompleter.py +++ b/mycli/sqlcompleter.py @@ -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()) @@ -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 @@ -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 @@ -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: @@ -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) diff --git a/test/myclirc b/test/myclirc index d3f272bb..37202862 100644 --- a/test/myclirc +++ b/test/myclirc @@ -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. diff --git a/test/pytests/test_client.py b/test/pytests/test_client.py index 0ca8b49c..2f2283fb 100644 --- a/test/pytests/test_client.py +++ b/test/pytests/test_client.py @@ -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]]] = [] diff --git a/test/pytests/test_client_query.py b/test/pytests/test_client_query.py index 080a12d0..4afccbeb 100644 --- a/test/pytests/test_client_query.py +++ b/test/pytests/test_client_query.py @@ -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), @@ -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'), }, ) ] @@ -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), @@ -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, diff --git a/test/pytests/test_sqlcompleter.py b/test/pytests/test_sqlcompleter.py index 1aa5e5ff..1d387ccd 100644 --- a/test/pytests/test_sqlcompleter.py +++ b/test/pytests/test_sqlcompleter.py @@ -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']